Di era digitalisasi saat ini, manajemen arsip yang baik adalah kunci efisiensi sebuah instansi. Pada artikel ini, kita akan membagikan Source Code Aplikasi Sistem Manajemen Surat Keluar Masuk berbasis Web yang sangat elegan, responsif, dan siap pakai.
Menariknya, aplikasi ini dibuat menggunakan teknologi Google Apps Script (GAS). Anda tidak perlu menyewa layanan *hosting* atau *database* berbayar, karena semuanya akan berjalan gratis di ekosistem server Google.
Apa itu Google Apps Script (GAS)?
Google Apps Script adalah platform pengembangan berbasis *Cloud* (komputasi awan) yang disediakan secara gratis oleh Google. Bahasa pemrogramannya didasarkan pada JavaScript standar. Platform ini memungkinkan Anda untuk membangun aplikasi web (*Web App*) dan mengotomatiskan tugas-tugas langsung di dalam produk Google Workspace, seperti Google Sheets, Google Docs, dan Gmail.
Manfaat GAS untuk Sistem Manajemen Surat
- 100% Gratis: Tidak ada biaya sewa server (Hosting) atau perpanjangan domain bulanan.
- Database Menggunakan Google Sheets: Data surat yang diinput akan tersimpan rapi layaknya tabel Excel di Google Sheets. Anda bisa memantau dan mem- *backup* data dengan sangat mudah.
- Aman dan Andal: Karena dijalankan di atas infrastruktur server Google, keamanan data dan *uptime* aplikasi dijamin oleh sistem keamanan tingkat tinggi milik Google.
- Kemudahan Cetak PDF: Sistem dapat diintegrasikan dengan *library* pihak ketiga untuk mencetak surat dan laporan langsung menjadi file PDF secara otomatis.
Langkah-Langkah Membuat Aplikasi E-Arsip
- Buka browser Anda dan kunjungi Google Sheets Baru.
- Beri nama *Spreadsheet* Anda, misalnya "Database E-Arsip".
- Klik menu Ekstensi di bagian atas, lalu pilih Apps Script.
- Hapus semua kode bawaan di file
Code.gs, lalu copy dan paste kode backend di bawah ini. - Buat file HTML baru dengan cara klik ikon (+) Tambahkan file > HTML, beri nama index (huruf kecil).
- Copy dan paste kode frontend ke dalam file
index.htmltersebut. - Pilih fungsi initDatabase di menu atas (sebelah tombol Run/Jalankan), lalu klik Jalankan untuk membuat struktur tabel otomatis. Berikan izin akses jika diminta.
- Terakhir, klik tombol Terapkan (Deploy) > Penerapan Baru, atur jenisnya ke Web App, dan bagikan akses ke "Siapa Saja".
Source Code Backend (Code.gs)
Kode ini berfungsi sebagai server yang menghubungkan antarmuka Web dengan Google Sheets Anda.
const SPREADSHEET_ID = SpreadsheetApp.getActiveSpreadsheet().getId();
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('E-Arsip Premium')
.addMetaTag('viewport', 'width=device-width, initial-scale=1')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function getSheetByName(name) { return SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(name); }
// BUG FIX #3: Kerentanan Auto-Increment ID diatasi dengan mencari nilai Maksimal (Math.max)
function autoIncrement(sheetName) {
const sheet = getSheetByName(sheetName);
const data = sheet.getDataRange().getValues();
if (data.length <= 1) return 1;
let maxId = 0;
for (let i = 1; i < data.length; i++) {
let currentId = parseInt(data[i][0]);
if (!isNaN(currentId) && currentId > maxId) {
maxId = currentId;
}
}
return maxId + 1;
}
function initDatabase() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheetsInfo = {
"Users": ["id", "nama", "email", "password", "role"],
"SuratMasuk": ["id", "no_surat", "pengirim", "perihal", "tanggal_surat", "tanggal_diterima", "klasifikasi", "prioritas", "status", "file_link"],
"SuratKeluar": ["id", "no_surat", "penerima", "perihal", "tanggal", "status", "file_link"],
"Disposisi": ["id", "surat_id", "dari", "kepada", "instruksi", "batas_waktu", "status"],
"TemplateSurat": ["id", "nama_template", "jenis", "isi_template"]
};
for (let sheetName in sheetsInfo) {
let sheet = ss.getSheetByName(sheetName);
if (!sheet) {
sheet = ss.insertSheet(sheetName);
sheet.appendRow(sheetsInfo[sheetName]);
sheet.getRange(1, 1, 1, sheetsInfo[sheetName].length).setFontWeight("bold").setBackground("#1f8a70").setFontColor("white");
}
}
const userSheet = ss.getSheetByName("Users");
if (userSheet.getLastRow() === 1) userSheet.appendRow([1, "Administrator", "admin@sekolah.com", "admin123", "Admin"]);
const configSheet = ss.getSheetByName("PengaturanApp");
if (!configSheet) {
let sheet = ss.insertSheet("PengaturanApp");
sheet.appendRow(["id", "pemerintah", "dinas", "sekolah", "alamat", "logo_kiri", "logo_kanan", "tempat_ttd", "nama_kepsek", "nip_kepsek"]);
sheet.appendRow([
1, "PEMERINTAH KABUPATEN KERINCI", "DINAS PENDIDIKAN", "SMP NEGERI 3 KERINCI",
"Jl. Pendidikan No. 1, Kerinci, Jambi, Kode Pos 38000",
"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Lambang_Kabupaten_Kerinci.png/200px-Lambang_Kabupaten_Kerinci.png",
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Logo_Tut_Wuri_Handayani.png/200px-Logo_Tut_Wuri_Handayani.png",
"Kerinci", "Hamdani, S.Pd.", "19700101 200003 1 001"
]);
sheet.getRange(1, 1, 1, 10).setFontWeight("bold").setBackground("#1f8a70").setFontColor("white");
}
return "Database berhasil diinisialisasi!";
}
// BUG FIX #1: Trigger Otomatis jika Database/Sheet belum ada saat user coba login perdana
function checkLogin(email, password) {
let sheet = getSheetByName('Users');
if (!sheet) {
initDatabase();
sheet = getSheetByName('Users'); // Reload sheet setelah dibuat
}
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][2] === email && data[i][3] === password) return { status: 'success', id: data[i][0], nama: data[i][1], email: data[i][2] };
}
return { status: 'error', message: 'Kredensial salah!' };
}
function getDashboardStats() {
const masuk = Math.max(0, getSheetByName('SuratMasuk').getLastRow() - 1);
const keluar = Math.max(0, getSheetByName('SuratKeluar').getLastRow() - 1);
const dispData = getSheetByName('Disposisi').getDataRange().getValues();
let pending = 0;
for (let i = 1; i < dispData.length; i++) if (dispData[i][6] === 'Pending') pending++;
return { status: 'success', data: { masuk, keluar, disposisi: pending, arsip: masuk + keluar } };
}
function getTableData(sheetName) {
try { return { status: 'success', data: getSheetByName(sheetName).getDataRange().getDisplayValues() }; }
catch(e) { return { status: 'error', message: e.message }; }
}
// BUG FIX #4: Penanganan Spasi nyasar pada nama Header menggunakan .trim()
function saveDataUniversal(sheetName, objData) {
try {
const sheet = getSheetByName(sheetName);
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const newId = autoIncrement(sheetName);
let rowToInsert = [newId];
for (let i = 1; i < headers.length; i++) {
let cleanHeader = String(headers[i]).trim(); // Menghapus spasi ekstra jika ada di Spreadsheet
rowToInsert.push(objData[cleanHeader] !== undefined ? objData[cleanHeader] : "");
}
sheet.appendRow(rowToInsert);
return { status: 'success', message: `Data tersimpan di ${sheetName}!` };
} catch (e) { return { status: 'error', message: e.message }; }
}
function deleteRow(sheetName, id) {
const sheet = getSheetByName(sheetName);
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] == id) { sheet.deleteRow(i + 1); return { status: 'success' }; }
}
return { status: 'error' };
}
function getPengaturanApp() {
const data = getSheetByName('PengaturanApp').getDataRange().getValues();
if(data.length > 1) return {status: 'success', data: data[1]};
return {status: 'error', message: 'Data pengaturan tidak ditemukan'};
}
// BUG FIX #2: Proxy otomatis merubah URL Logo jadi format Base64 untuk mem-bypass error CORS di Cetak PDF
function savePengaturanApp(obj) {
try {
function toBase64(url) {
if (!url || url.startsWith('data:image')) return url;
try {
const response = UrlFetchApp.fetch(url, {muteHttpExceptions: true});
const blob = response.getBlob();
return 'data:' + blob.getContentType() + ';base64,' + Utilities.base64Encode(blob.getBytes());
} catch(e) { return url; } // Fallback kembali ke url awal jika fetch gagal
}
// Convert di server side sebelum simpan ke sheet
const logoKiriSafe = toBase64(obj.logo_kiri);
const logoKananSafe = toBase64(obj.logo_kanan);
const sheet = getSheetByName('PengaturanApp');
const lastRow = sheet.getLastRow();
if (lastRow < 2) {
sheet.appendRow([1, obj.pemerintah, obj.dinas, obj.sekolah, obj.alamat, logoKiriSafe, logoKananSafe, obj.tempat_ttd, obj.nama_kepsek, obj.nip_kepsek]);
} else {
sheet.getRange(2, 2, 1, 9).setValues([[
obj.pemerintah, obj.dinas, obj.sekolah, obj.alamat,
logoKiriSafe, logoKananSafe, obj.tempat_ttd, obj.nama_kepsek, obj.nip_kepsek
]]);
}
return {status: 'success', message: 'Identitas & Kop Surat berhasil diperbarui!'};
} catch(e) {
return {status: 'error', message: e.message};
}
}
function updatePengaturanUser(userId, newEmail, newPassword) {
const sheet = getSheetByName('Users');
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] == userId) {
if (newEmail) sheet.getRange(i + 1, 3).setValue(newEmail);
if (newPassword) sheet.getRange(i + 1, 4).setValue(newPassword);
return { status: 'success', message: 'Akun diperbarui. Silakan login ulang.' };
}
}
}
Source Code Frontend (index.html)
Kode ini mengatur tampilan antarmuka aplikasi. Termasuk fitur Dashboard, Pop-up Modal, SweetAlert2, hingga integrasi cetak laporan ke bentuk PDF presisi 1 Lembar A4.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>E-Arsip Premium</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root { --primary: #1f8a70; --primary-dark: #166854; --bg-gradient: linear-gradient(135deg, #e0f2f1 0%, #b2dfdb 100%); }
body { background: var(--bg-gradient); font-family: 'Segoe UI', sans-serif; overflow-x: hidden; min-height: 100vh; }
/* UI KARTU & TOMBOL */
.card, .table-responsive { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 12px; border: none; box-shadow: 0 8px 20px rgba(31,138,112,0.1); }
.btn-primary { background-color: var(--primary); border-color: var(--primary); }
.btn-primary:hover { background-color: var(--primary-dark); border-color: var(--primary-dark); }
/* LOGIN */
#login-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(31,138,112,0.9), rgba(22,104,84,0.9)); }
.login-card { background: #fff; padding: 40px; border-radius: 15px; width: 100%; max-width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); margin: 20px; }
/* APP LAYOUT */
#app-wrapper { display: none; min-height: 100vh; flex-wrap: nowrap; position: relative; }
/* SIDEBAR & MOBILE OVERLAY */
.sidebar { width: 260px; min-width: 260px; flex-shrink: 0; background: rgba(255, 255, 255, 0.98); box-shadow: 2px 0 15px rgba(0,0,0,0.05); transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); z-index: 1040; display: flex; flex-direction: column; overflow-y: auto;}
.nav-link { color: #555; padding: 12px 20px; border-radius: 8px; margin: 4px 15px; cursor: pointer; font-weight: 500; white-space: nowrap; transition: 0.2s; }
.nav-link:hover, .nav-link.active { background: rgba(31,138,112,0.15); color: var(--primary); font-weight: 700; }
.top-navbar { background: rgba(255, 255, 255, 0.95); box-shadow: 0 2px 10px rgba(0,0,0,0.05); z-index: 1030; backdrop-filter: blur(5px); }
.stat-icon { width: 50px; height: 50px; display: flex; align-items: center; justify-content: center; border-radius: 12px; font-size: 1.5rem; }
.sidebar-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1035; backdrop-filter: blur(2px); transition: 0.3s; opacity: 0;}
.sidebar-overlay.show { display: block; opacity: 1; }
/* KERTAS A4 & PREVIEW WRAPPER */
.preview-container { width: 100%; overflow-x: auto; background-color: #f0f0f0; padding: 20px; border-radius: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.05); }
.kertas-a4 {
width: 210mm !important;
height: 297mm !important;
min-width: 210mm !important; /* Memaksa agar tidak mengecil di HP */
padding: 20mm !important;
margin: 0 auto;
background: white;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
color: black;
font-family: 'Times New Roman', Times, serif;
position: relative; /* Kunci TTD */
box-sizing: border-box !important;
overflow: hidden !important;
}
.laporan-print-area { background: white; padding: 20px; color: black; min-width: 800px; /* Laporan agar bisa di-scroll di HP */ }
.laporan-wrapper { overflow-x: auto; background: #f0f0f0; padding: 10px; border-radius: 10px; }
/* MEDIA QUERY MOBILE RESPONSIVE */
@media (max-width: 992px) {
.sidebar { position: fixed; left: -260px; height: 100vh; }
.sidebar.show { left: 0; }
.stat-icon { width: 40px; height: 40px; font-size: 1.2rem; }
.card h2 { font-size: 1.8rem; }
}
</style>
</head>
<body>
<div id="login-wrapper">
<div class="login-card text-center">
<div class="mb-4">
<div class="bg-light rounded-circle d-inline-block p-3 mb-2 shadow-sm"><i class="bi bi-archive-fill fs-1" style="color: var(--primary);"></i></div>
<h3 class="fw-bold mt-2" style="color: var(--primary);">E-Arsip</h3>
<p class="text-muted small">Sistem Manajemen Surat Digital</p>
</div>
<input type="email" id="loginEmail" class="form-control mb-3 py-2 bg-light" placeholder="Email (admin@sekolah.com)">
<input type="password" id="loginPass" class="form-control mb-4 py-2 bg-light" placeholder="Password (admin123)">
<button class="btn btn-primary w-100 py-2 fw-bold shadow-sm" onclick="doLogin()">Masuk Dashboard</button>
</div>
</div>
<div id="app-wrapper">
<div class="sidebar-overlay" id="mobileOverlay" onclick="toggleSidebar()"></div>
<nav class="sidebar py-3 border-end" id="sidebarMenu">
<div class="text-center mb-4 px-3 d-flex align-items-center justify-content-center mt-2">
<i class="bi bi-archive-fill fs-4 me-2" style="color: var(--primary);"></i><h4 class="fw-bold mb-0" style="color: var(--primary);">E-Arsip</h4>
</div>
<ul class="nav flex-column mb-auto pb-5">
<li><a class="nav-link active" onclick="loadView('dashboard', this)"><i class="bi bi-grid-1x2 me-3"></i> Dashboard</a></li>
<li class="mt-3 mb-1 px-4 text-muted small fw-bold text-uppercase">Data Utama</li>
<li><a class="nav-link" onclick="loadView('SuratMasuk', this)"><i class="bi bi-box-arrow-in-right me-3"></i> Surat Masuk</a></li>
<li><a class="nav-link" onclick="loadView('SuratKeluar', this)"><i class="bi bi-box-arrow-right me-3"></i> Surat Keluar</a></li>
<li><a class="nav-link" onclick="loadView('Disposisi', this)"><i class="bi bi-arrow-left-right me-3"></i> Disposisi</a></li>
<li class="mt-3 mb-1 px-4 text-muted small fw-bold text-uppercase">Cetak PDF</li>
<li><a class="nav-link" onclick="loadView('template', this)"><i class="bi bi-printer me-3"></i> Cetak Template</a></li>
<li><a class="nav-link" onclick="loadView('laporan', this)"><i class="bi bi-file-earmark-pdf me-3"></i> Cetak Laporan</a></li>
<li class="mt-3 mb-1 px-4 text-muted small fw-bold text-uppercase">Sistem</li>
<li><a class="nav-link" onclick="loadView('pengaturan', this)"><i class="bi bi-gear me-3"></i> Pengaturan</a></li>
</ul>
</nav>
<div class="flex-grow-1 d-flex flex-column overflow-hidden" style="width: 100%;">
<nav class="navbar top-navbar px-3 px-md-4 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<button class="btn btn-light d-lg-none me-2 me-md-3 shadow-sm" onclick="toggleSidebar()"><i class="bi bi-list fs-5"></i></button>
<h5 class="mb-0 fw-bold text-dark text-truncate" id="pageTitle" style="max-width: 180px;">Dashboard</h5>
</div>
<div class="dropdown">
<button class="btn btn-light dropdown-toggle rounded-pill px-3 px-md-4 shadow-sm fw-bold text-secondary" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1" style="color:var(--primary)"></i> <span id="userNameDisplay" class="d-none d-sm-inline">Admin</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0 mt-2">
<li><a class="dropdown-item py-2 text-danger fw-bold" href="#" onclick="logout()"><i class="bi bi-power me-2"></i>Logout</a></li>
</ul>
</div>
</nav>
<main class="p-3 p-md-4 flex-grow-1 overflow-auto">
<div id="view-dashboard" class="app-view">
<div class="row g-3 g-md-4 mb-4">
<div class="col-6 col-xl-3"><div class="card p-3 border-bottom border-4 border-success h-100"><div class="d-flex justify-content-between align-items-center flex-wrap gap-2"><div><h6 class="text-muted fw-bold mb-1" style="font-size:0.85rem;">Surat Masuk</h6><h2 class="mb-0 fw-bold" id="stat-masuk">0</h2></div><div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-inbox-fill"></i></div></div></div></div>
<div class="col-6 col-xl-3"><div class="card p-3 border-bottom border-4 border-info h-100"><div class="d-flex justify-content-between align-items-center flex-wrap gap-2"><div><h6 class="text-muted fw-bold mb-1" style="font-size:0.85rem;">Surat Keluar</h6><h2 class="mb-0 fw-bold" id="stat-keluar">0</h2></div><div class="stat-icon bg-info bg-opacity-10 text-info"><i class="bi bi-send-fill"></i></div></div></div></div>
<div class="col-6 col-xl-3"><div class="card p-3 border-bottom border-4 border-warning h-100"><div class="d-flex justify-content-between align-items-center flex-wrap gap-2"><div><h6 class="text-muted fw-bold mb-1" style="font-size:0.85rem;">Pending</h6><h2 class="mb-0 fw-bold" id="stat-disposisi">0</h2></div><div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-clock-fill"></i></div></div></div></div>
<div class="col-6 col-xl-3"><div class="card p-3 border-bottom border-4 border-primary h-100"><div class="d-flex justify-content-between align-items-center flex-wrap gap-2"><div><h6 class="text-muted fw-bold mb-1" style="font-size:0.85rem;">Total Arsip</h6><h2 class="mb-0 fw-bold" id="stat-arsip">0</h2></div><div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-archive-fill"></i></div></div></div></div>
</div>
<div class="card p-3 p-md-4"><h5 class="fw-bold mb-4 text-secondary"><i class="bi bi-bar-chart-fill me-2"></i>Statistik Volume</h5><div style="height: 250px;"><canvas id="arsipChart"></canvas></div></div>
</div>
<div id="view-generic" class="app-view d-none">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 gap-3">
<div class="input-group shadow-sm w-100" style="max-width: 400px;">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="searchInput" placeholder="Cari arsip..." onkeyup="filterTable()">
</div>
<button class="btn btn-primary shadow fw-bold px-4 py-2 w-100 w-md-auto" onclick="openModal()"><i class="bi bi-plus-circle-fill me-2"></i> Tambah Data Baru</button>
</div>
<div class="table-responsive p-0 p-md-3">
<table class="table table-hover align-middle mb-0"><thead class="table-light text-secondary" id="tableHead"></thead><tbody id="tableBody"></tbody></table>
</div>
</div>
<div id="view-pengaturan" class="app-view d-none">
<div class="row g-4">
<div class="col-lg-4">
<div class="card p-4 h-100">
<h5 class="fw-bold text-secondary mb-4 border-bottom pb-2"><i class="bi bi-shield-lock-fill me-2"></i>Keamanan Akun</h5>
<label class="form-label fw-bold text-muted small">Email Login</label>
<input type="email" id="set-email" class="form-control bg-light mb-3">
<label class="form-label fw-bold text-muted small">Password Baru</label>
<input type="password" id="set-pass" class="form-control bg-light mb-4" placeholder="(Kosongkan jika tetap)">
<button class="btn btn-warning w-100 fw-bold shadow-sm mt-auto" onclick="simpanPengaturanAkun()">Update Akun</button>
</div>
</div>
<div class="col-lg-8">
<div class="card p-4">
<h5 class="fw-bold text-secondary mb-4 border-bottom pb-2"><i class="bi bi-building me-2"></i>Identitas Instansi & Kop</h5>
<div class="row g-3">
<div class="col-md-6"><label class="form-label small fw-bold text-muted">Pemerintah Kab/Kota/Prov</label><input type="text" id="cfg-pemerintah" class="form-control"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-muted">Dinas Pendidikan</label><input type="text" id="cfg-dinas" class="form-control"></div>
<div class="col-12"><label class="form-label small fw-bold text-muted">Nama Sekolah / Instansi</label><input type="text" id="cfg-sekolah" class="form-control"></div>
<div class="col-12"><label class="form-label small fw-bold text-muted">Alamat Lengkap</label><input type="text" id="cfg-alamat" class="form-control"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-muted">Link Logo Kiri</label><input type="url" id="cfg-logokiri" class="form-control"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-muted">Link Logo Kanan</label><input type="url" id="cfg-logokanan" class="form-control"></div>
<div class="col-md-4"><label class="form-label small fw-bold text-muted">Tempat TTD</label><input type="text" id="cfg-tempat" class="form-control"></div>
<div class="col-md-4"><label class="form-label small fw-bold text-muted">Nama Kepala Sekolah</label><input type="text" id="cfg-namakepsek" class="form-control"></div>
<div class="col-md-4"><label class="form-label small fw-bold text-muted">NIP Kepala Sekolah</label><input type="text" id="cfg-nipkepsek" class="form-control"></div>
</div>
<button id="btnSimpanKop" class="btn btn-primary w-100 fw-bold shadow-sm mt-4" onclick="simpanPengaturanKop()"><i class="bi bi-save me-2"></i>Simpan Identitas Kop</button>
</div>
</div>
</div>
</div>
<div id="view-template" class="app-view d-none">
<div class="row g-4">
<div class="col-xl-4 col-lg-5">
<div class="card p-4">
<h5 class="fw-bold mb-3"><i class="bi bi-input-cursor-text me-2"></i>Input Surat</h5>
<label class="form-label text-muted small fw-bold">Nomor Surat</label><input type="text" id="tpl-nomor" class="form-control mb-3">
<label class="form-label text-muted small fw-bold">Nama Tujuan</label><input type="text" id="tpl-nama" class="form-control mb-3">
<label class="form-label text-muted small fw-bold">Isi Surat</label><textarea id="tpl-isi" class="form-control mb-4" rows="4"></textarea>
<div class="d-flex flex-column flex-sm-row gap-2">
<button class="btn btn-success w-100 fw-bold shadow-sm" onclick="generatePreviewSurat()">Preview</button>
<button class="btn btn-primary w-100 fw-bold shadow-sm" onclick="cetakPDF('surat-preview', 'Surat_Template', 'portrait')"><i class="bi bi-printer-fill me-1"></i> Cetak PDF</button>
</div>
</div>
</div>
<div class="col-xl-8 col-lg-7">
<div class="preview-container text-center">
<div class="kertas-a4 text-start" id="surat-preview">
<table style="width: 100%; border-bottom: 3px solid black; margin-bottom: 2px;">
<tr>
<td style="width: 15%; text-align: left; vertical-align: middle;"><img id="print-logo-kiri-1" src="" style="width:80px; height:auto; max-height: 80px; object-fit: contain;"></td>
<td style="width: 70%; text-align: center; vertical-align: middle;">
<h4 id="print-pemerintah-1" style="margin:0; font-weight:bold; font-family:'Times New Roman', serif;">PEMERINTAH</h4>
<h4 id="print-dinas-1" style="margin:0; font-weight:bold; font-family:'Times New Roman', serif;">DINAS</h4>
<h3 id="print-sekolah-1" style="margin:0; font-weight:bold; font-family:'Times New Roman', serif;">SEKOLAH</h3>
<p id="print-alamat-1" style="margin:0; font-size:12px; font-family:'Times New Roman', serif;">Alamat</p>
</td>
<td style="width: 15%; text-align: right; vertical-align: middle;"><img id="print-logo-kanan-1" src="" style="width:80px; height:auto; max-height: 80px; object-fit: contain;"></td>
</tr>
</table>
<div style="border-top: 1px solid black; margin-bottom: 20px;"></div>
<div class="mb-4 mt-2">
<table style="font-family: 'Times New Roman', Times, serif;">
<tr><td width="80">Nomor</td><td width="10">:</td><td><span id="prev-nomor">...</span></td></tr>
<tr><td>Lampiran</td><td>:</td><td>-</td></tr>
<tr><td>Perihal</td><td>:</td><td>Pemberitahuan Resmi</td></tr>
</table>
</div>
<div class="mb-4">Kepada Yth.<br><b id="prev-nama">...</b><br>di Tempat</div>
<div class="text-justify" style="line-height: 1.5;">
Dengan hormat,<br><br><span id="prev-isi">Isi surat akan muncul di sini...</span>
</div>
<div style="position: absolute; bottom: 30mm; right: 20mm; text-align: left; width: 250px;">
<span id="print-tempat-1">Tempat</span>, <span id="prev-tgl">...</span><br>
Kepala Sekolah,<br><br><br><br>
<b id="print-namakepsek-1" style="text-decoration:underline;">Nama Kepsek</b><br>
NIP. <span id="print-nipkepsek-1">NIP</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="view-laporan" class="app-view d-none">
<div class="card p-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center border-bottom pb-3 mb-4 gap-3">
<h5 class="fw-bold text-secondary mb-0"><i class="bi bi-journal-text me-2"></i>Agenda Laporan</h5>
<div class="d-flex gap-2 w-100 justify-content-md-end" style="max-width: 400px;">
<select id="laporan-jenis" class="form-select form-select-sm w-50" onchange="loadDataLaporan()"><option value="SuratMasuk">Masuk</option><option value="SuratKeluar">Keluar</option><option value="Disposisi">Disposisi</option></select>
<button class="btn btn-danger btn-sm fw-bold w-50 shadow-sm" onclick="cetakPDF('area-cetak-laporan', 'Laporan_Arsip', 'landscape')"><i class="bi bi-file-pdf-fill me-1"></i> PDF</button>
</div>
</div>
<div class="laporan-wrapper">
<div id="area-cetak-laporan" class="laporan-print-area">
<table style="width: 100%; border-bottom: 3px solid black; margin-bottom: 2px;">
<tr>
<td style="width: 15%; text-align: left; vertical-align: middle;"><img id="print-logo-kiri-2" src="" style="width:80px; height:auto; max-height: 80px; object-fit: contain;"></td>
<td style="width: 70%; text-align: center; vertical-align: middle;">
<h5 id="print-pemerintah-2" style="margin:0; font-weight:bold; font-family:'Times New Roman', serif;">PEMERINTAH</h5>
<h5 id="print-dinas-2" style="margin:0; font-weight:bold; font-family:'Times New Roman', serif;">DINAS</h5>
<h4 id="print-sekolah-2" style="margin:0; font-weight:bold; font-family:'Times New Roman', serif;">SEKOLAH</h4>
<p id="print-alamat-2" style="margin:0; font-size:12px; font-family:'Times New Roman', serif;">Alamat</p>
</td>
<td style="width: 15%; text-align: right; vertical-align: middle;"><img id="print-logo-kanan-2" src="" style="width:80px; height:auto; max-height: 80px; object-fit: contain;"></td>
</tr>
</table>
<div style="border-top: 1px solid black; margin-bottom: 20px;"></div>
<div class="text-center mb-4 mt-4">
<h5 class="fw-bold text-uppercase text-decoration-underline" id="lap-judul" style="font-family: 'Times New Roman', Times, serif;">LAPORAN SURAT</h5>
</div>
<table class="table table-bordered table-sm border-dark" id="tabel-laporan">
<thead class="table-light text-center align-middle" id="lap-head"></thead><tbody id="lap-body" style="font-size: 0.9rem;"></tbody>
</table>
<div class="d-flex justify-content-end mt-5" style="font-family: 'Times New Roman', Times, serif;">
<div class="text-left" style="width: 250px;">
<span id="print-tempat-2">Tempat</span>, <span id="lap-tgl">...</span><br>
Kepala Sekolah,<br><br><br><br>
<b id="print-namakepsek-2" style="text-decoration:underline;">Nama Kepsek</b><br>
NIP. <span id="print-nipkepsek-2">NIP</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="text-center py-3 bg-white border-top text-muted small mt-auto fw-bold">
© 2026 <a href="https://yefriharyanto.id" id="author-link" class="text-decoration-none" style="color:var(--primary)">Yefri Haryanto</a>. Security Protocol Active.
</footer>
</div>
</div>
<div class="modal fade" id="dynamicModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header text-white" style="background: var(--primary);"><h5 class="modal-title fw-bold" id="modalTitle">Form Input</h5><button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button></div>
<div class="modal-body p-3 p-md-4"><form id="dynamicForm"></form></div>
<div class="modal-footer bg-light"><button type="button" class="btn btn-secondary fw-bold" data-bs-dismiss="modal">Batal</button><button type="button" class="btn btn-primary fw-bold px-4" onclick="simpanData()">Simpan Data</button></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
let currentSheet = ''; let chartInstance = null; let modalInstance = null; let currentUser = null;
const formConfig = {
'SuratMasuk': [
{ id: 'no_surat', label: 'Nomor Surat', type: 'text', req: true }, { id: 'pengirim', label: 'Pengirim', type: 'text', req: true },
{ id: 'perihal', label: 'Perihal Surat', type: 'text', req: true }, { id: 'tanggal_surat', label: 'Tanggal Surat', type: 'date', req: true },
{ id: 'tanggal_diterima', label: 'Tanggal Diterima', type: 'date', req: true }, { id: 'klasifikasi', label: 'Klasifikasi', type: 'select', options: ['Biasa', 'Penting', 'Rahasia'], req: true },
{ id: 'prioritas', label: 'Prioritas', type: 'select', options: ['Normal', 'Tinggi', 'Segera'], req: true }, { id: 'status', label: 'Status', type: 'select', options: ['Diterima', 'Diproses', 'Selesai'], req: true },
{ id: 'file_link', label: 'Link File (G-Drive)', type: 'text', req: false }
],
'SuratKeluar': [
{ id: 'no_surat', label: 'Nomor Surat Keluar', type: 'text', req: true }, { id: 'penerima', label: 'Penerima / Tujuan', type: 'text', req: true },
{ id: 'perihal', label: 'Perihal', type: 'text', req: true }, { id: 'tanggal', label: 'Tanggal Kirim', type: 'date', req: true },
{ id: 'status', label: 'Status', type: 'select', options: ['Draft', 'Dikirim', 'Selesai'], req: true }, { id: 'file_link', label: 'Link File (G-Drive)', type: 'text', req: false }
],
'Disposisi': [
{ id: 'surat_id', label: 'ID/No Surat Terkait', type: 'text', req: true }, { id: 'dari', label: 'Dari (Pimpinan)', type: 'text', req: true },
{ id: 'kepada', label: 'Diteruskan Kepada', type: 'text', req: true }, { id: 'instruksi', label: 'Instruksi / Pesan', type: 'textarea', req: true },
{ id: 'batas_waktu', label: 'Batas Waktu', type: 'date', req: true }, { id: 'status', label: 'Status Disposisi', type: 'select', options: ['Pending', 'Selesai'], req: true }
]
};
document.addEventListener("DOMContentLoaded", () => {
const fl = document.getElementById("author-link");
if (!fl || !fl.href.includes("yefriharyanto.id") || !fl.innerText.includes("Yefri")) { document.body.innerHTML = `<div style="height:100vh;display:flex;align-items:center;justify-content:center;background:#f8d7da;color:#721c24;"><h2>Security Lock Active.</h2></div>`; return; }
modalInstance = new bootstrap.Modal(document.getElementById('dynamicModal'));
checkSession();
});
function toggleSidebar() {
document.getElementById('sidebarMenu').classList.toggle('show');
document.getElementById('mobileOverlay').classList.toggle('show');
}
function checkSession() {
const user = localStorage.getItem('earsip_v6');
if (user) {
currentUser = JSON.parse(user); document.getElementById('userNameDisplay').innerText = currentUser.nama;
document.getElementById('login-wrapper').style.display = 'none'; document.getElementById('app-wrapper').style.setProperty('display', 'flex', 'important');
loadDataPengaturanApp();
loadView('dashboard', document.querySelector('.nav-link.active'));
} else {
document.getElementById('login-wrapper').style.display = 'flex'; document.getElementById('app-wrapper').style.setProperty('display', 'none', 'important');
}
}
function doLogin() {
const em = document.getElementById('loginEmail').value, ps = document.getElementById('loginPass').value;
if(!em || !ps) return Swal.fire('Peringatan', 'Isi email dan password!', 'warning');
Swal.fire({ title: 'Memeriksa Data...', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); } });
google.script.run.withSuccessHandler(res => {
if(res.status === 'success') {
localStorage.setItem('earsip_v6', JSON.stringify(res));
Swal.fire({icon: 'success', title: 'Login Berhasil', showConfirmButton: false, timer: 1500}).then(() => checkSession());
} else Swal.fire('Gagal!', res.message, 'error');
}).checkLogin(em, ps);
}
function logout() { localStorage.removeItem('earsip_v6'); checkSession(); }
function loadDataPengaturanApp() {
google.script.run.withSuccessHandler(res => {
if(res.status === 'success') {
const d = res.data;
document.getElementById('cfg-pemerintah').value = d[1]; document.getElementById('cfg-dinas').value = d[2];
document.getElementById('cfg-sekolah').value = d[3]; document.getElementById('cfg-alamat').value = d[4];
document.getElementById('cfg-logokiri').value = d[5]; document.getElementById('cfg-logokanan').value = d[6];
document.getElementById('cfg-tempat').value = d[7]; document.getElementById('cfg-namakepsek').value = d[8];
document.getElementById('cfg-nipkepsek').value = d[9];
for(let i=1; i<=2; i++) {
document.getElementById('print-pemerintah-'+i).innerText = d[1]; document.getElementById('print-dinas-'+i).innerText = d[2];
document.getElementById('print-sekolah-'+i).innerText = d[3]; document.getElementById('print-alamat-'+i).innerText = d[4];
document.getElementById('print-logo-kiri-'+i).src = d[5]; document.getElementById('print-logo-kanan-'+i).src = d[6];
document.getElementById('print-tempat-'+i).innerText = d[7]; document.getElementById('print-namakepsek-'+i).innerText = d[8];
document.getElementById('print-nipkepsek-'+i).innerText = d[9];
}
}
}).getPengaturanApp();
}
function simpanPengaturanKop() {
const btn = document.getElementById('btnSimpanKop');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Menyimpan...'; btn.disabled = true;
Swal.fire({ title: 'Menyimpan Pengaturan...', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); }});
const obj = {
pemerintah: document.getElementById('cfg-pemerintah').value, dinas: document.getElementById('cfg-dinas').value,
sekolah: document.getElementById('cfg-sekolah').value, alamat: document.getElementById('cfg-alamat').value,
logo_kiri: document.getElementById('cfg-logokiri').value, logo_kanan: document.getElementById('cfg-logokanan').value,
tempat_ttd: document.getElementById('cfg-tempat').value, nama_kepsek: document.getElementById('cfg-namakepsek').value,
nip_kepsek: document.getElementById('cfg-nipkepsek').value
};
google.script.run.withSuccessHandler(res => {
btn.innerHTML = '<i class="bi bi-save me-2"></i>Simpan Identitas Kop'; btn.disabled = false;
if(res.status === 'success') { Swal.fire('Berhasil!', res.message, 'success'); loadDataPengaturanApp();
} else Swal.fire('Gagal!', res.message, 'error');
}).savePengaturanApp(obj);
}
function simpanPengaturanAkun() {
const em = document.getElementById('set-email').value, ps = document.getElementById('set-pass').value;
if(!em) return Swal.fire('Perhatian', 'Email wajib diisi!', 'warning');
Swal.fire({ title: 'Memperbarui Akun...', didOpen: () => { Swal.showLoading(); }});
google.script.run.withSuccessHandler(res => { Swal.fire('Sukses', res.message, 'success').then(() => logout()); }).updatePengaturanUser(currentUser.id, em, ps);
}
function loadView(viewName, element) {
document.querySelectorAll('.nav-link').forEach(el => el.classList.remove('active')); if(element) element.classList.add('active');
document.getElementById('pageTitle').innerText = (element ? element.innerText.trim() : viewName.toUpperCase());
document.querySelectorAll('.app-view').forEach(v => v.classList.add('d-none'));
if(window.innerWidth <= 992 && document.getElementById('sidebarMenu').classList.contains('show')) {
toggleSidebar();
}
if (viewName === 'dashboard') { document.getElementById('view-dashboard').classList.remove('d-none'); loadDashboard(); }
else if (['pengaturan', 'template', 'laporan'].includes(viewName)) {
document.getElementById('view-' + viewName).classList.remove('d-none');
if(viewName === 'pengaturan') document.getElementById('set-email').value = currentUser.email || '';
if(viewName === 'laporan') loadDataLaporan();
if(viewName === 'template') {
const tgl = new Date().toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' });
document.getElementById('prev-tgl').innerText = tgl; document.getElementById('lap-tgl').innerText = tgl;
}
} else { document.getElementById('view-generic').classList.remove('d-none'); currentSheet = viewName; renderTableData(viewName); }
}
function loadDashboard() {
google.script.run.withSuccessHandler(res => {
if(res.status === 'success') {
document.getElementById('stat-masuk').innerText = res.data.masuk; document.getElementById('stat-keluar').innerText = res.data.keluar;
document.getElementById('stat-disposisi').innerText = res.data.disposisi; document.getElementById('stat-arsip').innerText = res.data.arsip;
drawChart(res.data.masuk, res.data.keluar, res.data.disposisi);
}
}).getDashboardStats();
}
function openModal() {
const conf = formConfig[currentSheet]; let html = '<div class="row g-3">';
conf.forEach(f => {
html += `<div class="${f.type === 'textarea' ? 'col-12' : 'col-md-6'}"><label class="form-label small fw-bold text-muted">${f.label}</label>`;
if(f.type === 'select') { html += `<select class="form-select bg-light" id="input_${f.id}">`; f.options.forEach(opt => html += `<option value="${opt}">${opt}</option>`); html += `</select>`; }
else if (f.type === 'textarea') html += `<textarea class="form-control bg-light" id="input_${f.id}" rows="3"></textarea>`;
else html += `<input type="${f.type}" class="form-control bg-light" id="input_${f.id}">`;
html += `</div>`;
});
document.getElementById('dynamicForm').innerHTML = html + '</div>'; modalInstance.show();
}
function simpanData() {
let dataToSave = {}; formConfig[currentSheet].forEach(f => dataToSave[f.id] = document.getElementById(`input_${f.id}`).value);
Swal.fire({ title: 'Menyimpan Data...', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); }});
google.script.run.withSuccessHandler(res => { modalInstance.hide(); Swal.fire('Berhasil!', res.message, 'success'); renderTableData(currentSheet); }).saveDataUniversal(currentSheet, dataToSave);
}
function renderTableData(sheetName) {
const tHead = document.getElementById('tableHead'), tBody = document.getElementById('tableBody');
tBody.innerHTML = '<tr><td colspan="10" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
google.script.run.withSuccessHandler(res => {
if(res.data.length < 2) return tBody.innerHTML = '<tr><td colspan="10" class="text-center py-5">Belum ada data</td></tr>';
let headHTML = '<tr>'; res.data[0].forEach((col, i) => { if(i !== 0) headHTML += `<th class="text-nowrap">${col.toUpperCase().replace('_', ' ')}</th>`; });
tHead.innerHTML = headHTML + '<th class="text-end">AKSI</th></tr>';
let bodyHTML = '';
for(let i = 1; i < res.data.length; i++) {
bodyHTML += '<tr>'; const rowId = res.data[i][0];
res.data[i].forEach((cell, idx) => {
if(idx !== 0) {
if(['Selesai', 'Diterima', 'Dikirim'].includes(cell)) cell = `<span class="badge bg-success bg-opacity-10 text-success border border-success">${cell}</span>`;
else if(['Pending', 'Diproses', 'Draft'].includes(cell)) cell = `<span class="badge bg-warning bg-opacity-10 text-warning border border-warning">${cell}</span>`;
bodyHTML += `<td class="py-2 text-nowrap">${cell}</td>`;
}
});
bodyHTML += `<td class="text-end py-2"><button class="btn btn-sm btn-light text-danger shadow-sm" onclick="deleteRecord('${rowId}')"><i class="bi bi-trash-fill"></i></button></td></tr>`;
}
tBody.innerHTML = bodyHTML;
}).getTableData(sheetName);
}
function deleteRecord(id) {
Swal.fire({ title: 'Hapus Data?', text: "Data tidak bisa dikembalikan!", icon: 'warning', showCancelButton: true, confirmButtonColor: '#d33', cancelButtonColor: '#3085d6', confirmButtonText: 'Ya, hapus!'
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({ title: 'Menghapus...', didOpen: () => { Swal.showLoading(); }});
google.script.run.withSuccessHandler(() => { Swal.fire('Terhapus!', 'Data telah dihapus.', 'success'); renderTableData(currentSheet); }).deleteRow(currentSheet, id);
}
})
}
function generatePreviewSurat() {
document.getElementById('prev-nomor').innerText = document.getElementById('tpl-nomor').value || '...';
document.getElementById('prev-nama').innerText = document.getElementById('tpl-nama').value || '...';
document.getElementById('prev-isi').innerHTML = document.getElementById('tpl-isi').value.replace(/\n/g, '<br>') || '...';
}
function loadDataLaporan() {
const jenis = document.getElementById('laporan-jenis').value; document.getElementById('lap-judul').innerText = "LAPORAN " + jenis.toUpperCase().replace('SURAT', 'SURAT ');
const tHead = document.getElementById('lap-head'), tBody = document.getElementById('lap-body');
google.script.run.withSuccessHandler(res => {
if(res.data.length < 2) return tBody.innerHTML = '<tr><td colspan="10" class="text-center py-3">Tidak ada data</td></tr>';
let headHTML = '<tr><th>NO</th>'; res.data[0].forEach((col, i) => { if(i !== 0) headHTML += `<th class="text-nowrap">${col.toUpperCase().replace('_', ' ')}</th>`; });
tHead.innerHTML = headHTML + '</tr>'; let bodyHTML = '';
for(let i = 1; i < res.data.length; i++) { bodyHTML += `<tr><td class="text-center">${i}</td>`; res.data[i].forEach((cell, idx) => { if(idx !== 0) bodyHTML += `<td class="text-nowrap">${cell}</td>`; }); bodyHTML += `</tr>`; }
tBody.innerHTML = bodyHTML;
}).getTableData(jenis);
}
function cetakPDF(elementId, fileName, orientation) {
Swal.fire({ title: 'Menyiapkan Dokumen PDF...', text: 'Mohon tunggu sebentar', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); }});
const element = document.getElementById(elementId);
const opt = {
margin: 0,
filename: fileName + '_' + new Date().getTime() + '.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, scrollY: 0 },
jsPDF: { unit: 'mm', format: 'a4', orientation: orientation }
};
html2pdf().set(opt).from(element).save().then(() => {
Swal.fire('Selesai!', 'PDF telah berhasil diunduh.', 'success');
});
}
function filterTable() { const input = document.getElementById("searchInput").value.toLowerCase(), rows = document.getElementById("tableBody").getElementsByTagName("tr"); for (let i = 0; i < rows.length; i++) rows[i].style.display = rows[i].innerText.toLowerCase().includes(input) ? "" : "none"; }
function drawChart(m, k, d) {
if(chartInstance) chartInstance.destroy();
chartInstance = new Chart(document.getElementById('arsipChart').getContext('2d'), { type: 'bar', data: { labels: ['Volume'], datasets: [{ label: 'Masuk', data: [m], backgroundColor: '#198754', borderRadius: 6 }, { label: 'Keluar', data: [k], backgroundColor: '#0dcaf0', borderRadius: 6 }, { label: 'Disposisi', data: [d], backgroundColor: '#ffc107', borderRadius: 6 }] }, options: { responsive: true, maintainAspectRatio: false, plugins:{legend:{position:'bottom'}} } });
}
</script>
</body>
</html>
