Membuat Aplikasi Arsip Surat Dengan Google Apps Script
Pada tutorial ini kita akan membuat aplikasi Arsip Surat berbasis Google Apps Script dengan fitur:
- Dashboard Statistik
- Surat Masuk
- Surat Keluar
- Semua Arsip
- Ekspor CSV
- Refresh Data
- Pengaturan Sistem
- Upload Dokumen
- Grafik Tren Arsip
Catatan:
Ganti seluruh kode di bawaan dengan kode berikut.
Ganti seluruh kode di bawaan dengan kode berikut.
File Code.gs
Code.gs
/*************************************************
* // ============================================================
// ARSIP SURAT — Google Apps Script Backend
// Version 2.0 | 2025
// ============================================================
// ── CONFIG KEYS ─────────────────────────────────────────────
const CONFIG_KEY_SHEET_ID = 'AS_SHEET_ID';
const CONFIG_KEY_SHEET_NAME = 'AS_SHEET_NAME';
const CONFIG_KEY_ORG_NAME = 'AS_ORG_NAME';
const CONFIG_KEY_ORG_LOGO = 'AS_ORG_LOGO';
const DEFAULT_SHEET_NAME = 'ArsipSurat';
// ── HEADERS ─────────────────────────────────────────────────
const HEADERS = [
'ID', 'Jenis', 'Nomor Surat', 'Tanggal Surat', 'Perihal',
'Dari/Kepada', 'Klasifikasi', 'Status', 'Keterangan',
'Dibuat Oleh', 'Tanggal Input', 'Terakhir Diubah', 'File URL'
];
// ── ENTRY POINT ──────────────────────────────────────────────
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Arsip Surat')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0');
}
// ── USER PROPS HELPERS ───────────────────────────────────────
function getProps() {
return PropertiesService.getUserProperties();
}
// ── CONFIG: GET / SAVE ───────────────────────────────────────
function getConfig() {
const p = getProps();
return {
sheetId : p.getProperty(CONFIG_KEY_SHEET_ID) || '',
sheetName : p.getProperty(CONFIG_KEY_SHEET_NAME) || DEFAULT_SHEET_NAME,
orgName : p.getProperty(CONFIG_KEY_ORG_NAME) || 'Instansi / Organisasi',
orgLogo : p.getProperty(CONFIG_KEY_ORG_LOGO) || '',
};
}
function saveConfig(cfg) {
const p = getProps();
if (cfg.sheetId !== undefined) p.setProperty(CONFIG_KEY_SHEET_ID, cfg.sheetId);
if (cfg.sheetName !== undefined) p.setProperty(CONFIG_KEY_SHEET_NAME, cfg.sheetName || DEFAULT_SHEET_NAME);
if (cfg.orgName !== undefined) p.setProperty(CONFIG_KEY_ORG_NAME, cfg.orgName);
if (cfg.orgLogo !== undefined) p.setProperty(CONFIG_KEY_ORG_LOGO, cfg.orgLogo);
// Auto-init sheet if sheetId provided
if (cfg.sheetId) {
try { initSheet(); } catch(e) {}
}
return { success: true, message: 'Konfigurasi berhasil disimpan.' };
}
// ── SHEET RESOLVER ───────────────────────────────────────────
function getSheet(forWrite) {
const cfg = getConfig();
let ss;
if (cfg.sheetId) {
try { ss = SpreadsheetApp.openById(cfg.sheetId); }
catch(e) { throw new Error('Gagal membuka Spreadsheet. Pastikan ID benar dan akses sudah diberikan.'); }
} else {
ss = SpreadsheetApp.getActiveSpreadsheet();
if (!ss) throw new Error('Tidak ada Spreadsheet aktif. Silakan konfigurasi terlebih dahulu.');
}
let sheet = ss.getSheetByName(cfg.sheetName);
if (!sheet && forWrite) {
sheet = ss.insertSheet(cfg.sheetName);
_writeHeaders(sheet);
}
if (!sheet) throw new Error(`Sheet "${cfg.sheetName}" tidak ditemukan.`);
return sheet;
}
function initSheet() {
const sheet = getSheet(true);
if (sheet.getLastRow() === 0) _writeHeaders(sheet);
else if (sheet.getRange(1,1).getValue() !== 'ID') {
sheet.insertRowBefore(1);
_writeHeaders(sheet);
}
return { success: true };
}
function _writeHeaders(sheet) {
const r = sheet.getRange(1, 1, 1, HEADERS.length);
r.setValues([HEADERS]);
r.setBackground('#1e293b')
.setFontColor('#ffffff')
.setFontWeight('bold')
.setFontSize(10);
sheet.setFrozenRows(1);
sheet.setColumnWidth(1, 80);
sheet.setColumnWidth(3, 150);
sheet.setColumnWidth(5, 250);
sheet.setColumnWidth(6, 180);
}
// ── GENERATE ID ──────────────────────────────────────────────
function generateId() {
const now = new Date();
const year = now.getFullYear();
const rand = Math.floor(Math.random() * 9000) + 1000;
return `SRT-${year}-${rand}`;
}
// ── READ ALL ─────────────────────────────────────────────────
function getAllSurat() {
try {
const sheet = getSheet(false);
const last = sheet.getLastRow();
if (last < 2) return { success: true, data: [] };
const values = sheet.getRange(2, 1, last - 1, HEADERS.length).getValues();
const data = values
.filter(r => r[0] !== '')
.map(r => _rowToObj(r));
return { success: true, data };
} catch(e) {
return { success: false, message: e.message };
}
}
function _rowToObj(r) {
return {
id : r[0],
jenis : r[1],
nomorSurat : r[2],
tanggalSurat : r[3] ? Utilities.formatDate(new Date(r[3]), Session.getScriptTimeZone(), 'yyyy-MM-dd') : '',
perihal : r[4],
dariKepada : r[5],
klasifikasi : r[6],
status : r[7],
keterangan : r[8],
dibuatOleh : r[9],
tanggalInput : r[10] ? Utilities.formatDate(new Date(r[10]), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm') : '',
terakhirDiubah: r[11] ? Utilities.formatDate(new Date(r[11]), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm') : '',
fileUrl : r[12],
};
}
// ── CREATE ───────────────────────────────────────────────────
function createSurat(data) {
try {
const sheet = getSheet(true);
const id = generateId();
const now = new Date();
const user = Session.getActiveUser().getEmail() || 'Sistem';
const row = [
id,
data.jenis || '',
data.nomorSurat || '',
data.tanggalSurat ? new Date(data.tanggalSurat) : now,
data.perihal || '',
data.dariKepada || '',
data.klasifikasi || 'Biasa',
data.status || 'Aktif',
data.keterangan || '',
data.dibuatOleh || user,
now,
now,
data.fileUrl || '',
];
sheet.appendRow(row);
_styleLastRow(sheet);
return { success: true, id, message: 'Surat berhasil ditambahkan.' };
} catch(e) {
return { success: false, message: e.message };
}
}
function _styleLastRow(sheet) {
const last = sheet.getLastRow();
const r = sheet.getRange(last, 1, 1, HEADERS.length);
const bg = last % 2 === 0 ? '#f8fafc' : '#ffffff';
r.setBackground(bg).setFontSize(10).setVerticalAlignment('middle');
sheet.setRowHeight(last, 36);
}
// ── UPDATE ───────────────────────────────────────────────────
function updateSurat(data) {
try {
const sheet = getSheet(false);
const last = sheet.getLastRow();
if (last < 2) return { success: false, message: 'Data tidak ditemukan.' };
const ids = sheet.getRange(2, 1, last - 1, 1).getValues().flat();
const idx = ids.indexOf(data.id);
if (idx === -1) return { success: false, message: `ID ${data.id} tidak ditemukan.` };
const rowNum = idx + 2;
const existing = sheet.getRange(rowNum, 1, 1, HEADERS.length).getValues()[0];
const updated = [
data.id,
data.jenis || existing[1],
data.nomorSurat || existing[2],
data.tanggalSurat ? new Date(data.tanggalSurat) : existing[3],
data.perihal !== undefined ? data.perihal : existing[4],
data.dariKepada !== undefined ? data.dariKepada : existing[5],
data.klasifikasi || existing[6],
data.status || existing[7],
data.keterangan !== undefined ? data.keterangan : existing[8],
existing[9],
existing[10],
new Date(),
data.fileUrl !== undefined ? data.fileUrl : existing[12],
];
sheet.getRange(rowNum, 1, 1, HEADERS.length).setValues([updated]);
return { success: true, message: 'Surat berhasil diperbarui.' };
} catch(e) {
return { success: false, message: e.message };
}
}
// ── DELETE ───────────────────────────────────────────────────
function deleteSurat(id) {
try {
const sheet = getSheet(false);
const last = sheet.getLastRow();
if (last < 2) return { success: false, message: 'Data tidak ditemukan.' };
const ids = sheet.getRange(2, 1, last - 1, 1).getValues().flat();
const idx = ids.indexOf(id);
if (idx === -1) return { success: false, message: `ID ${id} tidak ditemukan.` };
sheet.deleteRow(idx + 2);
return { success: true, message: 'Surat berhasil dihapus.' };
} catch(e) {
return { success: false, message: e.message };
}
}
// ── SEARCH ───────────────────────────────────────────────────
function searchSurat(query, filters) {
try {
const result = getAllSurat();
if (!result.success) return result;
let data = result.data;
const q = (query || '').toLowerCase().trim();
if (q) {
data = data.filter(s =>
s.nomorSurat.toLowerCase().includes(q) ||
s.perihal.toLowerCase().includes(q) ||
s.dariKepada.toLowerCase().includes(q) ||
s.id.toLowerCase().includes(q)
);
}
if (filters) {
if (filters.jenis) data = data.filter(s => s.jenis === filters.jenis);
if (filters.status) data = data.filter(s => s.status === filters.status);
if (filters.klasifikasi) data = data.filter(s => s.klasifikasi === filters.klasifikasi);
if (filters.dateFrom) data = data.filter(s => s.tanggalSurat >= filters.dateFrom);
if (filters.dateTo) data = data.filter(s => s.tanggalSurat <= filters.dateTo);
}
return { success: true, data };
} catch(e) {
return { success: false, message: e.message };
}
}
// ── STATS ────────────────────────────────────────────────────
function getStats() {
try {
const result = getAllSurat();
if (!result.success) return result;
const data = result.data;
const total = data.length;
const masuk = data.filter(s => s.jenis === 'Masuk').length;
const keluar= data.filter(s => s.jenis === 'Keluar').length;
const aktif = data.filter(s => s.status === 'Aktif').length;
const arsip = data.filter(s => s.status === 'Diarsipkan').length;
const thisMonth = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM');
const bulanIni = data.filter(s => s.tanggalInput.startsWith(thisMonth)).length;
// Tren 6 bulan terakhir
const tren = [];
for (let i = 5; i >= 0; i--) {
const d = new Date();
d.setMonth(d.getMonth() - i);
const key = Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM');
const lbl = Utilities.formatDate(d, Session.getScriptTimeZone(), 'MMM yy');
tren.push({ bulan: lbl, total: data.filter(s => s.tanggalInput.startsWith(key)).length });
}
return { success: true, stats: { total, masuk, keluar, aktif, arsip, bulanIni, tren } };
} catch(e) {
return { success: false, message: e.message };
}
}
// ── EXPORT CSV ───────────────────────────────────────────────
function exportCsv() {
try {
const result = getAllSurat();
if (!result.success) return result;
const rows = [HEADERS.join(',')];
result.data.forEach(s => {
rows.push([
s.id, s.jenis, s.nomorSurat, s.tanggalSurat,
`"${(s.perihal||'').replace(/"/g,'""')}"`,
`"${(s.dariKepada||'').replace(/"/g,'""')}"`,
s.klasifikasi, s.status,
`"${(s.keterangan||'').replace(/"/g,'""')}"`,
s.dibuatOleh, s.tanggalInput, s.terakhirDiubah,
s.fileUrl
].join(','));
});
return { success: true, csv: rows.join('\n') };
} catch(e) {
return { success: false, message: e.message };
}
}
// ── SPREADSHEET INFO ─────────────────────────────────────────
function getSpreadsheetInfo() {
try {
const cfg = getConfig();
let ss;
if (cfg.sheetId) {
ss = SpreadsheetApp.openById(cfg.sheetId);
} else {
ss = SpreadsheetApp.getActiveSpreadsheet();
}
if (!ss) return { success: false, message: 'Tidak ada spreadsheet terhubung.' };
const sheets = ss.getSheets().map(s => s.getName());
return { success: true, name: ss.getName(), url: ss.getUrl(), sheets };
} catch(e) {
return { success: false, message: e.message };
}
}
*************************************************/
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index');
}
/*
Tambahkan seluruh kode Code.gs Anda
di area ini.
*/
File Index.html
Index.html
<!-- ********************************************<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Arsip Surat</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet"/>
<style>
/* ═══════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════ */
:root {
--navy: #0f172a;
--navy-2: #1e293b;
--navy-3: #334155;
--navy-4: #475569;
--slate: #64748b;
--muted: #94a3b8;
--border: #e2e8f0;
--surface: #f8fafc;
--white: #ffffff;
--accent: #3b82f6;
--accent-d: #2563eb;
--accent-l: #eff6ff;
--accent-bg: rgba(59,130,246,.08);
--green: #10b981;
--green-l: #ecfdf5;
--red: #ef4444;
--red-l: #fef2f2;
--amber: #f59e0b;
--amber-l: #fffbeb;
--purple: #8b5cf6;
--purple-l: #f5f3ff;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-xl: 20px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.07),0 1px 2px rgba(0,0,0,.05);
--shadow-md: 0 4px 16px rgba(0,0,0,.08),0 2px 6px rgba(0,0,0,.05);
--shadow-lg: 0 10px 40px rgba(0,0,0,.12),0 4px 12px rgba(0,0,0,.06);
--shadow-xl: 0 20px 60px rgba(0,0,0,.16);
--sidebar-w: 260px;
--topbar-h: 64px;
--font: 'Inter', system-ui, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
/* ── RESET ── */
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { font-family: var(--font); background: var(--surface); color: var(--navy); font-size: 14px; line-height: 1.5; }
button { font-family: inherit; cursor: pointer; border: none; outline: none; }
input, select, textarea { font-family: inherit; outline: none; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ═══════════════════════════════════
LAYOUT
═══════════════════════════════════ */
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
position: relative;
}
/* top gradient line */
.app-shell::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #6366f1, #8b5cf6);
z-index: 999;
}
/* ── SIDEBAR ── */
.sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
transition: width .25s ease, min-width .25s ease;
position: relative;
z-index: 50;
flex-shrink: 0;
}
.sidebar.collapsed { width: 64px; min-width: 64px; }
.sidebar-logo {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 16px 18px;
border-bottom: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
.logo-icon {
width: 38px; height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6, #6366f1);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; flex-shrink: 0;
box-shadow: 0 4px 12px rgba(59,130,246,.4);
}
.logo-text { overflow: hidden; white-space: nowrap; }
.logo-title { font-size: 14px; font-weight: 700; color: #fff; letter-spacing: -.2px; }
.logo-sub { font-size: 11px; color: var(--muted); }
.sidebar-nav {
flex: 1; padding: 10px 10px; display: flex; flex-direction: column; gap: 1px;
}
.nav-label {
font-size: 10px; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: #475569;
padding: 12px 8px 4px; white-space: nowrap; overflow: hidden;
}
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px; border-radius: var(--r-sm);
color: #94a3b8; cursor: pointer; transition: all .15s;
white-space: nowrap; overflow: hidden; position: relative;
user-select: none;
}
.nav-item i { width: 18px; text-align: center; font-size: 14px; flex-shrink: 0; }
.nav-item span { font-size: 13px; font-weight: 500; overflow: hidden; white-space: nowrap; }
.nav-item:hover { background: rgba(255,255,255,.06); color: #fff; }
.nav-item.active { background: rgba(59,130,246,.15); color: #3b82f6; }
.nav-item.active i { color: #3b82f6; }
.nav-badge {
margin-left: auto; background: #3b82f6; color: #fff;
font-size: 10px; font-weight: 700; padding: 2px 7px;
border-radius: 99px; flex-shrink: 0;
}
.sidebar-footer { padding: 10px; border-top: 1px solid rgba(255,255,255,.07); flex-shrink: 0; }
.sidebar-toggle {
position: absolute; right: -13px; top: 24px;
width: 26px; height: 26px; border-radius: 50%;
background: #334155; border: 2px solid var(--navy);
color: #94a3b8; display: flex; align-items: center; justify-content: center;
font-size: 10px; cursor: pointer; z-index: 60; transition: transform .25s, background .15s;
}
.sidebar-toggle:hover { background: #475569; color: #fff; }
.sidebar.collapsed .sidebar-toggle { transform: rotate(180deg); }
.sidebar.collapsed .logo-text,
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .nav-label,
.sidebar.collapsed .nav-badge { opacity: 0; pointer-events: none; width: 0; }
/* ── MAIN ── */
.main-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.topbar {
height: var(--topbar-h); background: var(--white);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 24px; gap: 16px;
flex-shrink: 0; box-shadow: var(--shadow-sm);
}
.topbar-title { font-size: 16px; font-weight: 700; color: var(--navy); }
.topbar-sub { font-size: 12px; color: var(--slate); margin-top: 1px; }
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; }
/* ── BUTTONS ── */
.btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 16px; border-radius: var(--r-sm);
font-size: 13px; font-weight: 500; transition: all .15s; white-space: nowrap; cursor: pointer;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-d); box-shadow: 0 4px 14px rgba(59,130,246,.35); }
.btn-outline { background: transparent; color: var(--navy-3); border: 1.5px solid var(--border); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-l); }
.btn-ghost { background: transparent; color: var(--slate); }
.btn-ghost:hover { background: var(--surface); color: var(--navy); }
.btn-danger { background: var(--red); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.icon-btn {
width: 34px; height: 34px; border-radius: var(--r-sm);
background: var(--surface); border: 1px solid var(--border);
color: var(--slate); display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all .15s; font-size: 13px; flex-shrink: 0;
}
.icon-btn:hover { background: var(--accent-l); color: var(--accent); border-color: var(--accent); }
.icon-btn.danger:hover { background: var(--red-l); color: var(--red); border-color: var(--red); }
/* ── CONTENT ── */
.content-area { flex: 1; overflow-y: auto; padding: 24px; }
.page { display: none; }
.page.active { display: block; animation: fadeIn .2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* ═══════════════════════════════════
DASHBOARD
═══════════════════════════════════ */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(175px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 20px 18px;
box-shadow: var(--shadow-sm); transition: transform .18s, box-shadow .18s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.stat-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; }
.stat-icon {
width: 42px; height: 42px; border-radius: var(--r-md);
display: flex; align-items: center; justify-content: center; font-size: 17px;
}
.si-blue { background: #eff6ff; color: #3b82f6; }
.si-green { background: #ecfdf5; color: #10b981; }
.si-purple { background: #f5f3ff; color: #8b5cf6; }
.si-amber { background: #fffbeb; color: #f59e0b; }
.si-red { background: #fef2f2; color: #ef4444; }
.stat-val { font-size: 30px; font-weight: 700; line-height: 1; color: var(--navy); }
.stat-label { font-size: 12px; color: var(--slate); margin-top: 4px; }
.progress-bar {
height: 5px; background: var(--border);
border-radius: 99px; overflow: hidden; margin-top: 10px;
}
.progress-fill {
height: 100%; border-radius: 99px;
background: linear-gradient(90deg, #3b82f6, #6366f1);
transition: width .6s ease;
}
/* chart */
.chart-card {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 20px 24px;
box-shadow: var(--shadow-sm); margin-bottom: 24px;
}
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.card-title { font-size: 14px; font-weight: 600; color: var(--navy); }
.card-sub { font-size: 12px; color: var(--slate); margin-top: 2px; }
.bar-chart { display: flex; align-items: flex-end; gap: 10px; height: 130px; padding-top: 24px; }
.bar-col-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; }
.bar-inner { width: 100%; display: flex; align-items: flex-end; flex: 1; }
.bar {
width: 100%; border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #60a5fa, #3b82f6);
min-height: 4px; transition: height .5s ease; position: relative; cursor: pointer;
}
.bar:hover { background: linear-gradient(180deg, #93c5fd, #2563eb); }
.bar::before {
content: attr(data-val);
position: absolute; top: -22px; left: 50%; transform: translateX(-50%);
font-size: 11px; font-weight: 700; color: var(--navy-3); white-space: nowrap;
}
.bar-lbl { font-size: 10px; color: var(--slate); white-space: nowrap; text-align: center; }
/* section */
.section-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px;
}
.section-title { font-size: 14px; font-weight: 600; color: var(--navy); }
/* ═══════════════════════════════════
TABLE
═══════════════════════════════════ */
.toolbar {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 14px 18px;
margin-bottom: 16px; display: flex; flex-wrap: wrap;
align-items: center; gap: 10px; box-shadow: var(--shadow-sm);
}
.search-box {
display: flex; align-items: center; gap: 8px;
background: var(--surface); border: 1.5px solid var(--border);
border-radius: var(--r-sm); padding: 7px 12px;
flex: 1; min-width: 200px; transition: border-color .15s;
}
.search-box:focus-within { border-color: var(--accent); background: #fff; }
.search-box i { color: var(--muted); font-size: 13px; flex-shrink: 0; }
.search-box input { background: none; border: none; flex: 1; font-size: 13px; color: var(--navy); }
.search-box input::placeholder { color: var(--muted); }
.filter-sel {
background: var(--surface); border: 1.5px solid var(--border);
border-radius: var(--r-sm); padding: 7px 12px; font-size: 13px;
color: var(--navy-3); cursor: pointer; transition: border-color .15s;
}
.filter-sel:focus { border-color: var(--accent); }
.count-chip {
font-size: 12px; color: var(--slate); background: var(--surface);
border: 1px solid var(--border); border-radius: 99px; padding: 4px 12px;
white-space: nowrap; flex-shrink: 0;
}
/* table wrap */
.table-card {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--r-lg); box-shadow: var(--shadow-sm); overflow: hidden;
}
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 860px; }
thead { background: var(--navy); }
thead th {
padding: 12px 14px; text-align: left; font-size: 10.5px;
font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
color: #64748b; white-space: nowrap;
}
thead th.sort { cursor: pointer; user-select: none; }
thead th.sort:hover { color: #fff; }
thead th.asc::after { content: ' ↑'; color: #60a5fa; }
thead th.desc::after { content: ' ↓'; color: #60a5fa; }
tbody tr { border-bottom: 1px solid var(--border); transition: background .1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #f0f7ff; }
tbody td { padding: 11px 14px; font-size: 13px; color: var(--navy-3); vertical-align: middle; }
.td-id { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; }
.td-main { font-weight: 500; color: var(--navy); max-width: 220px; }
.td-main small { display: block; font-size: 11px; font-weight: 400; color: var(--slate); margin-top: 1px; }
.td-date { font-size: 12px; white-space: nowrap; color: var(--slate); }
.td-act { white-space: nowrap; }
/* badges */
.badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px; border-radius: 99px; font-size: 11px; font-weight: 600; white-space: nowrap;
}
.bm { background: #eff6ff; color: #3b82f6; }
.bk { background: #f5f3ff; color: #7c3aed; }
.ba { background: #ecfdf5; color: #059669; }
.bar2{ background: #fffbeb; color: #b45309; }
.bh { background: #fef2f2; color: #dc2626; }
.br { background: #fdf2f8; color: #9d174d; }
.bp { background: #fff7ed; color: #c2410c; }
.bb { background: var(--surface); color: var(--slate); border: 1px solid var(--border); }
.action-row { display: flex; gap: 5px; }
/* pagination */
.pag-wrap {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 18px; border-top: 1px solid var(--border); flex-wrap: wrap; gap: 10px;
}
.pag-info { font-size: 12px; color: var(--slate); }
.pag-btns { display: flex; gap: 4px; }
.pag-btn {
width: 32px; height: 32px; border-radius: var(--r-sm);
background: var(--surface); border: 1px solid var(--border);
color: var(--navy-3); font-size: 12px; display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all .12s;
}
.pag-btn:hover { background: var(--accent-l); color: var(--accent); border-color: var(--accent); }
.pag-btn.on { background: var(--accent); color: #fff; border-color: var(--accent); }
.pag-btn:disabled { opacity: .35; pointer-events: none; }
/* ═══════════════════════════════════
MODAL
═══════════════════════════════════ */
.overlay {
position: fixed; inset: 0; background: rgba(15,23,42,.6);
backdrop-filter: blur(4px); z-index: 200;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .2s; padding: 16px;
}
.overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--white); border-radius: var(--r-xl);
box-shadow: var(--shadow-xl); width: 100%; max-width: 600px;
max-height: 92vh; overflow-y: auto;
transform: translateY(16px) scale(.97); transition: transform .22s ease;
}
.overlay.open .modal { transform: translateY(0) scale(1); }
.modal-lg { max-width: 680px; }
.modal-sm { max-width: 420px; }
.modal-hd {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px 16px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--white); z-index: 2;
border-radius: var(--r-xl) var(--r-xl) 0 0;
}
.modal-title { font-size: 16px; font-weight: 700; color: var(--navy); }
.modal-sub { font-size: 12px; color: var(--slate); }
.modal-x {
width: 32px; height: 32px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--slate); display: flex; align-items: center;
justify-content: center; cursor: pointer; font-size: 13px; transition: all .15s;
}
.modal-x:hover { background: var(--red-l); color: var(--red); border-color: var(--red); }
.modal-bd { padding: 22px 24px; }
.modal-ft {
display: flex; justify-content: flex-end; gap: 10px;
padding: 14px 24px 20px; border-top: 1px solid var(--border);
}
/* form */
.fgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.fgrid .full { grid-column: 1 / -1; }
.ffield { display: flex; flex-direction: column; gap: 5px; }
.flabel {
font-size: 12px; font-weight: 600; color: var(--navy-3);
display: flex; align-items: center; gap: 3px;
}
.flabel .req { color: var(--red); }
.finput, .fsel, .ftarea {
background: var(--surface); border: 1.5px solid var(--border);
border-radius: var(--r-sm); padding: 9px 12px; font-size: 13px;
color: var(--navy); transition: border-color .15s, background .15s; width: 100%;
}
.finput:focus, .fsel:focus, .ftarea:focus {
border-color: var(--accent); background: #fff;
box-shadow: 0 0 0 3px rgba(59,130,246,.1);
}
.ftarea { min-height: 80px; resize: vertical; }
.fhint { font-size: 11px; color: var(--muted); }
/* ═══════════════════════════════════
DETAIL PANEL
═══════════════════════════════════ */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.detail-full { grid-column: 1 / -1; }
.dl { font-size: 10.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
.dv { font-size: 13px; color: var(--navy); margin-top: 4px; font-weight: 500; }
.dv.mono { font-family: var(--mono); font-size: 12px; }
.dv a { color: var(--accent); }
/* ═══════════════════════════════════
SETTINGS
═══════════════════════════════════ */
.settings-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 20px; }
.settings-sidenav {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--r-lg); overflow: hidden;
box-shadow: var(--shadow-sm); height: fit-content;
}
.snav-item {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
font-size: 13px; font-weight: 500; color: var(--navy-3);
cursor: pointer; transition: all .15s; border-bottom: 1px solid var(--border);
}
.snav-item:last-child { border-bottom: none; }
.snav-item:hover { background: var(--surface); color: var(--navy); }
.snav-item.active { background: var(--accent-l); color: var(--accent); }
.snav-item i { width: 16px; text-align: center; font-size: 13px; }
.spanel {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 26px; box-shadow: var(--shadow-sm);
}
.spanel-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.spanel-sub { font-size: 12px; color: var(--slate); margin-bottom: 22px; }
.sdivider { height: 1px; background: var(--border); margin: 20px 0; }
.info-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-md); padding: 16px; margin-top: 16px;
}
.info-row {
display: flex; align-items: flex-start; gap: 10px;
font-size: 12px; color: var(--navy-3); margin-bottom: 8px; line-height: 1.5;
}
.info-row:last-child { margin-bottom: 0; }
.info-row i { color: var(--muted); width: 14px; margin-top: 2px; flex-shrink: 0; }
.info-row a { color: var(--accent); }
/* ═══════════════════════════════════
EMPTY / LOADING / TOAST
═══════════════════════════════════ */
.empty-state {
text-align: center; padding: 56px 20px; color: var(--muted);
}
.empty-state .ei { font-size: 52px; margin-bottom: 14px; opacity: .25; display: block; }
.empty-state h3 { font-size: 15px; font-weight: 600; color: var(--slate); margin-bottom: 6px; }
.empty-state p { font-size: 13px; }
.empty-state button { margin-top: 16px; }
.spinner-row td { text-align: center; padding: 48px 20px; }
.spinner {
width: 30px; height: 30px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin .65s linear infinite; margin: 0 auto 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-txt { font-size: 13px; color: var(--muted); }
.toast-wrap {
position: fixed; bottom: 24px; right: 24px; z-index: 500;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
display: flex; align-items: center; gap: 11px; padding: 12px 16px;
border-radius: var(--r-md); background: var(--navy-2); color: #fff;
font-size: 13px; font-weight: 500; box-shadow: var(--shadow-lg);
min-width: 240px; max-width: 360px; animation: toastIn .22s ease;
}
@keyframes toastIn { from { transform: translateX(110%); opacity: 0; } to { transform: none; opacity: 1; } }
.toast i { font-size: 15px; flex-shrink: 0; }
.toast span { flex: 1; }
.toast .tx { opacity: .55; cursor: pointer; font-size: 12px; flex-shrink: 0; }
.toast .tx:hover { opacity: 1; }
.t-success i { color: #10b981; }
.t-error i { color: #ef4444; }
.t-warning i { color: #f59e0b; }
.t-info i { color: #60a5fa; }
/* ── Confirm modal ── */
.confirm-body { text-align: center; padding: 32px 24px; }
.confirm-icon { font-size: 40px; margin-bottom: 12px; }
.confirm-body h3 { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
.confirm-body p { font-size: 13px; color: var(--slate); }
.confirm-actions { margin-top: 24px; display: flex; justify-content: center; gap: 10px; }
/* ── responsive ── */
@media (max-width: 768px) {
:root { --sidebar-w: 260px; }
.sidebar { position: fixed; transform: translateX(-100%); }
.sidebar.mob-open { transform: none; }
.settings-wrap { grid-template-columns: 1fr; }
.fgrid { grid-template-columns: 1fr; }
.fgrid .full { grid-column: 1; }
.stat-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="app-shell">
<!-- ══════════ SIDEBAR ══════════ -->
<aside class="sidebar" id="sidebar">
<button class="sidebar-toggle" onclick="toggleSidebar()"><i class="fa fa-chevron-left"></i></button>
<div class="sidebar-logo">
<div class="logo-icon"><i class="fa fa-folder-open"></i></div>
<div class="logo-text">
<div class="logo-title" id="sbOrgName">Arsip Surat</div>
<div class="logo-sub">Manajemen Dokumen</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-label">Utama</div>
<div class="nav-item active" data-page="dashboard" onclick="showPage('dashboard')">
<i class="fa fa-chart-pie"></i><span>Dashboard</span>
</div>
<div class="nav-item" data-page="surat-masuk" onclick="showPage('surat-masuk')">
<i class="fa fa-inbox"></i><span>Surat Masuk</span>
<span class="nav-badge" id="bdgMasuk">0</span>
</div>
<div class="nav-item" data-page="surat-keluar" onclick="showPage('surat-keluar')">
<i class="fa fa-paper-plane"></i><span>Surat Keluar</span>
<span class="nav-badge" id="bdgKeluar">0</span>
</div>
<div class="nav-item" data-page="semua-surat" onclick="showPage('semua-surat')">
<i class="fa fa-list-ul"></i><span>Semua Arsip</span>
</div>
<div class="nav-label">Alat</div>
<div class="nav-item" onclick="doExportCsv()">
<i class="fa fa-file-csv"></i><span>Ekspor CSV</span>
</div>
<div class="nav-item" onclick="refreshAll()">
<i class="fa fa-rotate-right"></i><span>Refresh Data</span>
</div>
<div class="nav-label">Sistem</div>
<div class="nav-item" data-page="settings" onclick="showPage('settings')">
<i class="fa fa-gear"></i><span>Pengaturan</span>
</div>
</nav>
<div class="sidebar-footer">
<div style="font-size:11px;color:#475569;padding:6px 8px;white-space:nowrap;overflow:hidden;">
<i class="fa fa-shield-halved" style="margin-right:6px;"></i><span>v2.0 · Google Apps Script</span>
</div>
</div>
</aside>
<!-- ══════════ MAIN ══════════ -->
<div class="main-area">
<!-- TOPBAR -->
<header class="topbar">
<button class="icon-btn" id="mobMenuBtn" onclick="toggleMobile()" style="display:none;">
<i class="fa fa-bars"></i>
</button>
<div>
<div class="topbar-title" id="tbTitle">Dashboard</div>
<div class="topbar-sub" id="tbSub">Ringkasan arsip surat</div>
</div>
<div class="topbar-actions">
<button class="icon-btn" onclick="refreshAll()" title="Refresh data"><i class="fa fa-rotate-right"></i></button>
<button class="btn btn-primary" onclick="openAdd()">
<i class="fa fa-plus"></i><span>Tambah Surat</span>
</button>
</div>
</header>
<!-- PAGES -->
<div class="content-area">
<!-- ── DASHBOARD ── -->
<div class="page active" id="page-dashboard">
<div class="stat-grid">
<div class="stat-card">
<div class="stat-top">
<div><div class="stat-val" id="stTotal">—</div><div class="stat-label">Total Arsip</div></div>
<div class="stat-icon si-blue"><i class="fa fa-folder-open"></i></div>
</div>
<div class="progress-bar"><div class="progress-fill" id="pfTotal" style="width:0%"></div></div>
</div>
<div class="stat-card">
<div class="stat-top">
<div><div class="stat-val" id="stMasuk">—</div><div class="stat-label">Surat Masuk</div></div>
<div class="stat-icon si-green"><i class="fa fa-inbox"></i></div>
</div>
<div class="progress-bar"><div class="progress-fill" id="pfMasuk" style="width:0%;background:linear-gradient(90deg,#10b981,#059669)"></div></div>
</div>
<div class="stat-card">
<div class="stat-top">
<div><div class="stat-val" id="stKeluar">—</div><div class="stat-label">Surat Keluar</div></div>
<div class="stat-icon si-purple"><i class="fa fa-paper-plane"></i></div>
</div>
<div class="progress-bar"><div class="progress-fill" id="pfKeluar" style="width:0%;background:linear-gradient(90deg,#8b5cf6,#7c3aed)"></div></div>
</div>
<div class="stat-card">
<div class="stat-top">
<div><div class="stat-val" id="stBulan">—</div><div class="stat-label">Bulan Ini</div></div>
<div class="stat-icon si-amber"><i class="fa fa-calendar-day"></i></div>
</div>
</div>
<div class="stat-card">
<div class="stat-top">
<div><div class="stat-val" id="stAktif">—</div><div class="stat-label">Aktif</div></div>
<div class="stat-icon si-green"><i class="fa fa-circle-check"></i></div>
</div>
</div>
<div class="stat-card">
<div class="stat-top">
<div><div class="stat-val" id="stArsip">—</div><div class="stat-label">Diarsipkan</div></div>
<div class="stat-icon si-amber"><i class="fa fa-archive"></i></div>
</div>
</div>
</div>
<div class="chart-card">
<div class="card-header">
<div>
<div class="card-title">Tren Arsip 6 Bulan Terakhir</div>
<div class="card-sub">Jumlah surat diinput per bulan</div>
</div>
</div>
<div class="bar-chart" id="barChart">
<div style="color:var(--muted);font-size:13px;margin:auto;">Memuat data…</div>
</div>
</div>
<div class="section-header">
<div class="section-title"><i class="fa fa-clock-rotate-left" style="margin-right:7px;color:var(--accent);"></i>Arsip Terbaru</div>
<button class="btn btn-outline btn-sm" onclick="showPage('semua-surat')">Lihat Semua →</button>
</div>
<div class="table-card">
<div class="table-scroll">
<table>
<thead><tr>
<th>ID</th><th>Jenis</th><th>Nomor Surat</th>
<th>Perihal</th><th>Dari/Kepada</th><th>Tanggal</th><th>Status</th><th>Aksi</th>
</tr></thead>
<tbody id="tbRecent">
<tr class="spinner-row"><td colspan="8"><div class="spinner"></div><div class="loading-txt">Memuat…</div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── SURAT MASUK ── -->
<div class="page" id="page-surat-masuk">
<div class="toolbar">
<div class="search-box">
<i class="fa fa-magnifying-glass"></i>
<input id="q-masuk" type="text" placeholder="Cari nomor, perihal, pengirim…" oninput="doSearch('masuk')"/>
</div>
<select class="filter-sel" id="fs-masuk" onchange="doSearch('masuk')">
<option value="">Semua Status</option>
<option value="Aktif">Aktif</option>
<option value="Diarsipkan">Diarsipkan</option>
<option value="Dihapus">Dihapus</option>
</select>
<select class="filter-sel" id="fk-masuk" onchange="doSearch('masuk')">
<option value="">Semua Klasifikasi</option>
<option value="Biasa">Biasa</option>
<option value="Penting">Penting</option>
<option value="Rahasia">Rahasia</option>
<option value="Sangat Rahasia">Sangat Rahasia</option>
</select>
<span class="count-chip" id="cnt-masuk">0 arsip</span>
<button class="btn btn-primary btn-sm" onclick="openAdd('Masuk')"><i class="fa fa-plus"></i> Tambah</button>
</div>
<div class="table-card">
<div class="table-scroll">
<table>
<thead><tr>
<th class="sort" onclick="sortTable('masuk',0)">ID</th>
<th>Nomor Surat</th>
<th class="sort" onclick="sortTable('masuk',1)">Perihal</th>
<th>Dari</th>
<th class="sort" onclick="sortTable('masuk',2)">Tanggal</th>
<th>Klasifikasi</th><th>Status</th><th>Aksi</th>
</tr></thead>
<tbody id="tb-masuk">
<tr class="spinner-row"><td colspan="8"><div class="spinner"></div><div class="loading-txt">Memuat…</div></td></tr>
</tbody>
</table>
</div>
<div class="pag-wrap" id="pg-masuk"></div>
</div>
</div>
<!-- ── SURAT KELUAR ── -->
<div class="page" id="page-surat-keluar">
<div class="toolbar">
<div class="search-box">
<i class="fa fa-magnifying-glass"></i>
<input id="q-keluar" type="text" placeholder="Cari nomor, perihal, tujuan…" oninput="doSearch('keluar')"/>
</div>
<select class="filter-sel" id="fs-keluar" onchange="doSearch('keluar')">
<option value="">Semua Status</option>
<option value="Aktif">Aktif</option>
<option value="Diarsipkan">Diarsipkan</option>
<option value="Dihapus">Dihapus</option>
</select>
<select class="filter-sel" id="fk-keluar" onchange="doSearch('keluar')">
<option value="">Semua Klasifikasi</option>
<option value="Biasa">Biasa</option>
<option value="Penting">Penting</option>
<option value="Rahasia">Rahasia</option>
<option value="Sangat Rahasia">Sangat Rahasia</option>
</select>
<span class="count-chip" id="cnt-keluar">0 arsip</span>
<button class="btn btn-primary btn-sm" onclick="openAdd('Keluar')"><i class="fa fa-plus"></i> Tambah</button>
</div>
<div class="table-card">
<div class="table-scroll">
<table>
<thead><tr>
<th class="sort" onclick="sortTable('keluar',0)">ID</th>
<th>Nomor Surat</th>
<th class="sort" onclick="sortTable('keluar',1)">Perihal</th>
<th>Kepada</th>
<th class="sort" onclick="sortTable('keluar',2)">Tanggal</th>
<th>Klasifikasi</th><th>Status</th><th>Aksi</th>
</tr></thead>
<tbody id="tb-keluar">
<tr class="spinner-row"><td colspan="8"><div class="spinner"></div><div class="loading-txt">Memuat…</div></td></tr>
</tbody>
</table>
</div>
<div class="pag-wrap" id="pg-keluar"></div>
</div>
</div>
<!-- ── SEMUA ARSIP ── -->
<div class="page" id="page-semua-surat">
<div class="toolbar">
<div class="search-box">
<i class="fa fa-magnifying-glass"></i>
<input id="q-semua" type="text" placeholder="Cari di seluruh arsip…" oninput="doSearch('semua')"/>
</div>
<select class="filter-sel" id="fj-semua" onchange="doSearch('semua')">
<option value="">Semua Jenis</option>
<option value="Masuk">Masuk</option>
<option value="Keluar">Keluar</option>
</select>
<select class="filter-sel" id="fs-semua" onchange="doSearch('semua')">
<option value="">Semua Status</option>
<option value="Aktif">Aktif</option>
<option value="Diarsipkan">Diarsipkan</option>
<option value="Dihapus">Dihapus</option>
</select>
<select class="filter-sel" id="fk-semua" onchange="doSearch('semua')">
<option value="">Semua Klasifikasi</option>
<option value="Biasa">Biasa</option>
<option value="Penting">Penting</option>
<option value="Rahasia">Rahasia</option>
<option value="Sangat Rahasia">Sangat Rahasia</option>
</select>
<span class="count-chip" id="cnt-semua">0 arsip</span>
</div>
<div class="table-card">
<div class="table-scroll">
<table>
<thead><tr>
<th>ID</th><th>Jenis</th><th>Nomor Surat</th>
<th>Perihal</th><th>Dari/Kepada</th>
<th class="sort" onclick="sortTable('semua',0)">Tanggal</th>
<th>Klasifikasi</th><th>Status</th><th>Aksi</th>
</tr></thead>
<tbody id="tb-semua">
<tr class="spinner-row"><td colspan="9"><div class="spinner"></div><div class="loading-txt">Memuat…</div></td></tr>
</tbody>
</table>
</div>
<div class="pag-wrap" id="pg-semua"></div>
</div>
</div>
<!-- ── SETTINGS ── -->
<div class="page" id="page-settings">
<div class="settings-wrap">
<div class="settings-sidenav">
<div class="snav-item active" onclick="showSpanel('sheet')"><i class="fa fa-table-cells"></i> Google Sheet</div>
<div class="snav-item" onclick="showSpanel('org')"><i class="fa fa-building"></i> Organisasi</div>
<div class="snav-item" onclick="showSpanel('about')"><i class="fa fa-circle-info"></i> Tentang</div>
</div>
<div>
<!-- Sheet panel -->
<div class="spanel" id="sp-sheet">
<div class="spanel-title">Konfigurasi Google Sheet</div>
<div class="spanel-sub">Hubungkan aplikasi ke Spreadsheet Google Anda. Data akan otomatis tersimpan di sana.</div>
<div class="ffield" style="margin-bottom:14px;">
<label class="flabel">ID Spreadsheet</label>
<input class="finput" id="cfgId" placeholder="Tempel Spreadsheet ID…"/>
<div class="fhint">Ambil dari URL: …/spreadsheets/d/<strong>ID_INI</strong>/edit</div>
</div>
<div class="ffield" style="margin-bottom:14px;">
<label class="flabel">Nama Sheet / Tab</label>
<input class="finput" id="cfgName" placeholder="ArsipSurat"/>
<div class="fhint">Nama tab di dalam spreadsheet. Dibuat otomatis jika belum ada. Default: ArsipSurat</div>
</div>
<div class="info-card" id="sheetInfoBox" style="display:none;">
<div class="info-row"><i class="fa fa-circle-check" style="color:var(--green);"></i><strong>Terhubung ke:</strong></div>
<div class="info-row"><i class="fa fa-table"></i><span id="siName">—</span></div>
<div class="info-row"><i class="fa fa-link"></i><a id="siUrl" href="#" target="_blank">Buka Spreadsheet ↗</a></div>
<div class="info-row"><i class="fa fa-layer-group"></i>Sheet: <span id="siSheets">—</span></div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:20px;">
<button class="btn btn-primary" onclick="saveSettings()"><i class="fa fa-floppy-disk"></i> Simpan</button>
<button class="btn btn-outline" onclick="testConn()"><i class="fa fa-plug"></i> Test Koneksi</button>
<button class="btn btn-outline" onclick="initSht()"><i class="fa fa-table"></i> Inisialisasi Sheet</button>
</div>
</div>
<!-- Org panel -->
<div class="spanel" id="sp-org" style="display:none;">
<div class="spanel-title">Identitas Organisasi</div>
<div class="spanel-sub">Nama yang ditampilkan di sidebar dan header aplikasi.</div>
<div class="ffield" style="margin-bottom:14px;">
<label class="flabel">Nama Instansi / Organisasi</label>
<input class="finput" id="cfgOrg" placeholder="Contoh: Dinas Kesehatan Kota Manado"/>
</div>
<div class="ffield" style="margin-bottom:14px;">
<label class="flabel">URL Logo <span style="color:var(--muted);font-weight:400;">(opsional)</span></label>
<input class="finput" id="cfgLogo" placeholder="https://…"/>
</div>
<button class="btn btn-primary" style="margin-top:8px;" onclick="saveSettings()"><i class="fa fa-floppy-disk"></i> Simpan</button>
</div>
<!-- About panel -->
<div class="spanel" id="sp-about" style="display:none;">
<div class="spanel-title">Tentang Aplikasi</div>
<div class="spanel-sub">Informasi sistem dan versi.</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div><div class="dl">Nama Aplikasi</div><div class="dv">Arsip Surat</div></div>
<div><div class="dl">Versi</div><div class="dv">2.0.0</div></div>
<div><div class="dl">Platform</div><div class="dv">Google Apps Script + Google Sheets</div></div>
<div><div class="dl">Dibuat Dengan</div><div class="dv">HTML · CSS · JavaScript · GAS</div></div>
<div class="sdivider"></div>
<div><div class="dl">Fitur</div>
<div class="dv" style="font-weight:400;font-size:13px;color:var(--slate);line-height:1.8;">
✓ Surat Masuk & Keluar<br>
✓ Pencarian & Filter Multi-dimensi<br>
✓ Klasifikasi & Status Surat<br>
✓ Lampiran via Google Drive<br>
✓ Ekspor CSV<br>
✓ Tren Statistik 6 Bulan<br>
✓ Konfigurasi Spreadsheet Langsung dari App<br>
✓ Pagination & Sorting
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /content-area -->
</div><!-- /main-area -->
</div><!-- /app-shell -->
<!-- ══════════ MODAL: TAMBAH / EDIT ══════════ -->
<div class="overlay" id="ovForm">
<div class="modal modal-lg">
<div class="modal-hd">
<div>
<div class="modal-title" id="fmTitle">Tambah Surat Baru</div>
<div class="modal-sub" id="fmSub">Isi semua kolom yang bertanda *</div>
</div>
<button class="modal-x" onclick="closeOv('ovForm')"><i class="fa fa-xmark"></i></button>
</div>
<div class="modal-bd">
<div class="fgrid">
<!-- row 1 -->
<div class="ffield">
<label class="flabel">Jenis Surat <span class="req">*</span></label>
<select class="fsel" id="fJenis" onchange="updateLabel()">
<option value="">— Pilih Jenis —</option>
<option value="Masuk">📥 Surat Masuk</option>
<option value="Keluar">📤 Surat Keluar</option>
</select>
</div>
<div class="ffield">
<label class="flabel">Tanggal Surat <span class="req">*</span></label>
<input type="date" class="finput" id="fTanggal"/>
</div>
<!-- row 2 -->
<div class="ffield full">
<label class="flabel">Nomor Surat <span class="req">*</span></label>
<input class="finput" id="fNomor" placeholder="Contoh: 045/SK-VII/2025"/>
</div>
<!-- row 3 -->
<div class="ffield full">
<label class="flabel">Perihal <span class="req">*</span></label>
<input class="finput" id="fPerihal" placeholder="Ringkasan isi surat…"/>
</div>
<!-- row 4 -->
<div class="ffield full">
<label class="flabel" id="fDKLabel">Dari / Kepada <span class="req">*</span></label>
<input class="finput" id="fDariKepada" placeholder="Nama instansi / perorangan…"/>
</div>
<!-- row 5 -->
<div class="ffield">
<label class="flabel">Klasifikasi</label>
<select class="fsel" id="fKlasif">
<option value="Biasa">Biasa</option>
<option value="Penting">⚠️ Penting</option>
<option value="Rahasia">🔒 Rahasia</option>
<option value="Sangat Rahasia">🔐 Sangat Rahasia</option>
</select>
</div>
<div class="ffield">
<label class="flabel">Status</label>
<select class="fsel" id="fStatus">
<option value="Aktif">Aktif</option>
<option value="Diarsipkan">Diarsipkan</option>
<option value="Dihapus">Dihapus</option>
</select>
</div>
<!-- row 6 -->
<div class="ffield full">
<label class="flabel">Dibuat Oleh</label>
<input class="finput" id="fDibuat" placeholder="Nama pegawai / petugas (opsional)"/>
<div class="fhint">Kosongkan untuk menggunakan email akun Google Anda secara otomatis.</div>
</div>
<!-- row 7 -->
<div class="ffield full">
<label class="flabel">URL File di Google Drive <span style="color:var(--muted);font-weight:400;">(opsional)</span></label>
<input class="finput" id="fFileUrl" placeholder="https://drive.google.com/file/d/…"/>
</div>
<!-- row 8 -->
<div class="ffield full">
<label class="flabel">Keterangan Tambahan</label>
<textarea class="ftarea" id="fKet" placeholder="Catatan, disposisi, atau keterangan lainnya…"></textarea>
</div>
</div>
</div>
<div class="modal-ft">
<button class="btn btn-ghost" onclick="closeOv('ovForm')">Batal</button>
<button class="btn btn-primary" id="btnSimpan" onclick="simpan()">
<i class="fa fa-floppy-disk"></i> Simpan
</button>
</div>
</div>
</div>
<!-- ══════════ MODAL: DETAIL ══════════ -->
<div class="overlay" id="ovDetail">
<div class="modal modal-lg">
<div class="modal-hd">
<div>
<div class="modal-title">Detail Surat</div>
<div class="modal-sub" id="dtId">—</div>
</div>
<button class="modal-x" onclick="closeOv('ovDetail')"><i class="fa fa-xmark"></i></button>
</div>
<div class="modal-bd">
<div class="detail-grid" id="dtContent"></div>
</div>
<div class="modal-ft">
<button class="btn btn-ghost" onclick="closeOv('ovDetail')">Tutup</button>
<button class="btn btn-outline" onclick="editFromDetail()"><i class="fa fa-pen"></i> Edit</button>
</div>
</div>
</div>
<!-- ══════════ MODAL: KONFIRMASI HAPUS ══════════ -->
<div class="overlay" id="ovDel">
<div class="modal modal-sm">
<div class="confirm-body">
<div class="confirm-icon">🗑️</div>
<h3>Hapus Surat?</h3>
<p>Data akan dihapus permanen dari spreadsheet dan tidak dapat dipulihkan.</p>
<div class="confirm-actions">
<button class="btn btn-outline" onclick="closeOv('ovDel')">Batal</button>
<button class="btn btn-danger" id="btnDel" onclick="doDelete()"><i class="fa fa-trash"></i> Ya, Hapus</button>
</div>
</div>
</div>
</div>
<!-- ══════════ TOAST ══════════ -->
<div class="toast-wrap" id="toastWrap"></div>
<script>
// ═══════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════
const S = {
all : [], // all data from sheet
filtered : { masuk:[], keluar:[], semua:[] },
page : { masuk:1, keluar:1, semua:1 },
sort : { masuk:{col:-1,dir:1}, keluar:{col:-1,dir:1}, semua:{col:-1,dir:1} },
editId : null,
deleteId : null,
detailId : null,
};
const PAGE_SIZE = 15;
const PAGE_META = {
'dashboard' : ['Dashboard', 'Ringkasan arsip surat'],
'surat-masuk' : ['Surat Masuk', 'Daftar surat yang diterima'],
'surat-keluar': ['Surat Keluar', 'Daftar surat yang dikirim'],
'semua-surat' : ['Semua Arsip', 'Seluruh data arsip surat'],
'settings' : ['Pengaturan', 'Konfigurasi aplikasi & Google Sheet'],
};
// ═══════════════════════════════════════════════
// NAVIGATION
// ═══════════════════════════════════════════════
function showPage(id) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + id).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const ni = document.querySelector(`[data-page="${id}"]`);
if (ni) ni.classList.add('active');
const m = PAGE_META[id] || [id, ''];
document.getElementById('tbTitle').textContent = m[0];
document.getElementById('tbSub').textContent = m[1];
if (id === 'surat-masuk') { S.filtered.masuk = S.all.filter(x => x.jenis==='Masuk'); renderTbl('masuk'); }
if (id === 'surat-keluar') { S.filtered.keluar = S.all.filter(x => x.jenis==='Keluar'); renderTbl('keluar'); }
if (id === 'semua-surat') { S.filtered.semua = [...S.all]; renderTbl('semua'); }
if (id === 'settings') { loadSettings(); }
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
}
function toggleMobile() {
document.getElementById('sidebar').classList.toggle('mob-open');
}
// ═══════════════════════════════════════════════
// DATA
// ═══════════════════════════════════════════════
function refreshAll() {
loadStats();
loadData();
}
function loadStats() {
google.script.run
.withSuccessHandler(r => {
if (!r.success) return toast(r.message, 'error');
const s = r.stats;
set('stTotal', s.total);
set('stMasuk', s.masuk);
set('stKeluar', s.keluar);
set('stBulan', s.bulanIni);
set('stAktif', s.aktif);
set('stArsip', s.arsip);
set('bdgMasuk', s.masuk);
set('bdgKeluar', s.keluar);
const mx = Math.max(...s.tren.map(t => t.total), 1);
el('pfTotal').style.width = '100%';
el('pfMasuk').style.width = s.total ? (s.masuk /s.total*100)+'%' : '0%';
el('pfKeluar').style.width = s.total ? (s.keluar/s.total*100)+'%' : '0%';
renderChart(s.tren, mx);
})
.withFailureHandler(e => toast(e.message,'error'))
.getStats();
}
function loadData() {
google.script.run
.withSuccessHandler(r => {
if (!r.success) return toast(r.message, 'error');
S.all = r.data;
renderRecent();
const cur = document.querySelector('.page.active');
if (!cur) return;
const pid = cur.id.replace('page-','');
if (pid==='surat-masuk') { S.filtered.masuk = S.all.filter(x=>x.jenis==='Masuk'); renderTbl('masuk'); }
if (pid==='surat-keluar') { S.filtered.keluar = S.all.filter(x=>x.jenis==='Keluar'); renderTbl('keluar'); }
if (pid==='semua-surat') { S.filtered.semua = [...S.all]; renderTbl('semua'); }
})
.withFailureHandler(e => toast(e.message,'error'))
.getAllSurat();
}
// ═══════════════════════════════════════════════
// CHART
// ═══════════════════════════════════════════════
function renderChart(tren, mx) {
const c = el('barChart');
c.innerHTML = '';
tren.forEach(t => {
const pct = mx > 0 ? Math.max((t.total/mx)*100, 3) : 3;
c.innerHTML += `
<div class="bar-col-wrap">
<div class="bar-inner">
<div class="bar" style="height:${pct}%" data-val="${t.total}"></div>
</div>
<div class="bar-lbl">${t.bulan}</div>
</div>`;
});
}
// ═══════════════════════════════════════════════
// RECENT TABLE
// ═══════════════════════════════════════════════
function renderRecent() {
const tb = el('tbRecent');
const d = S.all.slice(-8).reverse();
if (!d.length) {
tb.innerHTML = emptyRow(8, 'Belum ada arsip', 'Klik Tambah Surat untuk memulai');
return;
}
tb.innerHTML = d.map(s => `<tr>
<td class="td-id">${s.id}</td>
<td>${bJenis(s.jenis)}</td>
<td style="font-weight:500">${x(s.nomorSurat)}</td>
<td class="td-main">${x(s.perihal)}</td>
<td>${x(s.dariKepada)}</td>
<td class="td-date">${s.tanggalSurat}</td>
<td>${bStatus(s.status)}</td>
<td class="td-act"><div class="action-row">
${s.fileUrl ? `<button class="icon-btn" title="Buka File" onclick="window.open('${x(s.fileUrl)}','_blank')"><i class="fa fa-file-arrow-up"></i></button>` : ''}
<button class="icon-btn" title="Detail" onclick="openDetail('${s.id}')"><i class="fa fa-eye"></i></button>
<button class="icon-btn" title="Edit" onclick="openEdit('${s.id}')"><i class="fa fa-pen"></i></button>
<button class="icon-btn danger" title="Hapus" onclick="openDel('${s.id}')"><i class="fa fa-trash"></i></button>
</div></td>
</tr>`).join('');
}
// ═══════════════════════════════════════════════
// MAIN TABLE
// ═══════════════════════════════════════════════
function renderTbl(type, pg) {
const isSemua = type === 'semua';
const tbody = el('tb-' + type);
const pagDiv = el('pg-' + type);
const cntEl = el('cnt-' + type);
if (!tbody) return;
const data = S.filtered[type];
pg = pg || S.page[type] || 1;
S.page[type] = pg;
if (cntEl) cntEl.textContent = data.length + ' arsip';
if (!data.length) {
tbody.innerHTML = emptyRow(isSemua ? 9 : 8, 'Tidak ada data', 'Coba ubah filter atau tambah surat baru.');
if (pagDiv) pagDiv.innerHTML = '';
return;
}
const total = data.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (pg - 1) * PAGE_SIZE;
const chunk = data.slice(start, start + PAGE_SIZE);
tbody.innerHTML = chunk.map(s => isSemua ? rowSemua(s) : rowJenis(type, s)).join('');
// pagination
if (pagDiv) {
const info = `<div class="pag-info">Menampilkan ${start+1}–${Math.min(start+PAGE_SIZE,total)} dari ${total} arsip</div>`;
let btns = `<div class="pag-btns">`;
btns += pagBtn('‹', pg > 1, `goPage('${type}',${pg-1})`);
const s2 = Math.max(1, Math.min(pg-2, pages-4));
const e2 = Math.min(pages, s2+4);
for (let i=s2;i<=e2;i++) btns += pagBtn(i, true, `goPage('${type}',${i})`, i===pg);
btns += pagBtn('›', pg < pages, `goPage('${type}',${pg+1})`);
btns += `</div>`;
pagDiv.innerHTML = info + btns;
}
}
function goPage(type, pg) { S.page[type] = pg; renderTbl(type, pg); }
function pagBtn(lbl, enabled, fn, active=false) {
return `<button class="pag-btn${active?' on':''}" ${enabled?`onclick="${fn}"`:'disabled'}>${lbl}</button>`;
}
function rowJenis(type, s) {
return `<tr>
<td class="td-id">${s.id}</td>
<td style="font-weight:500">${x(s.nomorSurat)}</td>
<td class="td-main">${x(s.perihal)}</td>
<td>${x(s.dariKepada)}</td>
<td class="td-date">${s.tanggalSurat}</td>
<td>${bKlasif(s.klasifikasi)}</td>
<td>${bStatus(s.status)}</td>
<td class="td-act">${actBtns(s)}</td>
</tr>`;
}
function rowSemua(s) {
return `<tr>
<td class="td-id">${s.id}</td>
<td>${bJenis(s.jenis)}</td>
<td style="font-weight:500">${x(s.nomorSurat)}</td>
<td class="td-main">${x(s.perihal)}</td>
<td>${x(s.dariKepada)}</td>
<td class="td-date">${s.tanggalSurat}</td>
<td>${bKlasif(s.klasifikasi)}</td>
<td>${bStatus(s.status)}</td>
<td class="td-act">${actBtns(s)}</td>
</tr>`;
}
function actBtns(s) {
return `<div class="action-row">
${s.fileUrl ? `<button class="icon-btn" title="Buka File" onclick="window.open('${x(s.fileUrl)}','_blank')"><i class="fa fa-file-arrow-up"></i></button>` : ''}
<button class="icon-btn" title="Detail" onclick="openDetail('${s.id}')"><i class="fa fa-eye"></i></button>
<button class="icon-btn" title="Edit" onclick="openEdit('${s.id}')"><i class="fa fa-pen"></i></button>
<button class="icon-btn danger" title="Hapus" onclick="openDel('${s.id}')"><i class="fa fa-trash"></i></button>
</div>`;
}
// ═══════════════════════════════════════════════
// SEARCH & FILTER
// ═══════════════════════════════════════════════
function doSearch(type) {
const q = (val('q-' + type)).toLowerCase();
const fs = val('fs-' + type);
const fk = val('fk-' + type);
const fj = type==='semua' ? val('fj-semua') : (type==='masuk'?'Masuk':'Keluar');
let src = type === 'semua' ? [...S.all] : S.all.filter(s => s.jenis === (type==='masuk'?'Masuk':'Keluar'));
if (type === 'semua' && fj) src = src.filter(s => s.jenis === fj);
if (q) src = src.filter(s => [s.nomorSurat,s.perihal,s.dariKepada,s.id].join(' ').toLowerCase().includes(q));
if (fs) src = src.filter(s => s.status === fs);
if (fk) src = src.filter(s => s.klasifikasi === fk);
S.filtered[type] = src;
S.page[type] = 1;
renderTbl(type);
}
// ═══════════════════════════════════════════════
// SORT
// ═══════════════════════════════════════════════
const SORT_KEYS = { masuk:['id','perihal','tanggalSurat'], keluar:['id','perihal','tanggalSurat'], semua:['tanggalSurat'] };
function sortTable(type, col) {
const ss = S.sort[type];
if (ss.col === col) ss.dir *= -1;
else { ss.col = col; ss.dir = 1; }
const key = SORT_KEYS[type][col];
S.filtered[type].sort((a,b) => {
const av = (a[key]||'').toString(), bv = (b[key]||'').toString();
return av.localeCompare(bv) * ss.dir;
});
// update header classes
const ths = document.querySelectorAll(`#page-${type==='semua'?'semua-surat':'surat-'+type} thead th.sort`);
ths.forEach((th,i) => { th.classList.remove('asc','desc'); if(i===col) th.classList.add(ss.dir===1?'asc':'desc'); });
renderTbl(type);
}
// ═══════════════════════════════════════════════
// FORM MODAL
// ═══════════════════════════════════════════════
function openAdd(jenis) {
S.editId = null;
el('fmTitle').textContent = 'Tambah Surat Baru';
el('fmSub').textContent = 'Isi semua kolom yang bertanda *';
clearForm();
el('fTanggal').value = today();
if (jenis) el('fJenis').value = jenis;
updateLabel();
openOv('ovForm');
}
function openEdit(id) {
const s = S.all.find(x => x.id === id);
if (!s) return toast('Data tidak ditemukan','error');
S.editId = id;
el('fmTitle').textContent = 'Edit Surat';
el('fmSub').textContent = id;
el('fJenis').value = s.jenis;
el('fTanggal').value = s.tanggalSurat;
el('fNomor').value = s.nomorSurat;
el('fPerihal').value = s.perihal;
el('fDariKepada').value = s.dariKepada;
el('fKlasif').value = s.klasifikasi;
el('fStatus').value = s.status;
el('fDibuat').value = s.dibuatOleh || '';
el('fFileUrl').value = s.fileUrl || '';
el('fKet').value = s.keterangan || '';
updateLabel();
openOv('ovForm');
}
function clearForm() {
['fJenis','fNomor','fPerihal','fDariKepada','fFileUrl','fKet','fDibuat'].forEach(id => { const e=el(id); if(e) e.value=''; });
el('fKlasif').value = 'Biasa';
el('fStatus').value = 'Aktif';
}
function updateLabel() {
const j = el('fJenis').value;
el('fDKLabel').innerHTML = (j==='Keluar' ? 'Kepada' : 'Dari') + ' <span class="req">*</span>';
el('fDariKepada').placeholder = j==='Keluar' ? 'Nama / instansi penerima…' : 'Nama / instansi pengirim…';
}
function simpan() {
const data = {
jenis : val('fJenis'),
tanggalSurat:val('fTanggal'),
nomorSurat : val('fNomor'),
perihal : val('fPerihal'),
dariKepada : val('fDariKepada'),
klasifikasi: val('fKlasif'),
status : val('fStatus'),
dibuatOleh : val('fDibuat'),
fileUrl : val('fFileUrl'),
keterangan : val('fKet'),
};
if (!data.jenis) return toast('Pilih jenis surat dahulu','warning');
if (!data.nomorSurat) return toast('Nomor surat wajib diisi','warning');
if (!data.perihal) return toast('Perihal wajib diisi','warning');
if (!data.dariKepada) return toast('Dari/Kepada wajib diisi','warning');
if (!data.tanggalSurat)return toast('Tanggal surat wajib diisi','warning');
const btn = el('btnSimpan');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Menyimpan…';
const cb = r => {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-floppy-disk"></i> Simpan';
if (r.success) { toast(r.message,'success'); closeOv('ovForm'); refreshAll(); }
else toast(r.message, 'error');
};
const eb = e => { btn.disabled=false; btn.innerHTML='<i class="fa fa-floppy-disk"></i> Simpan'; toast(e.message,'error'); };
if (S.editId) {
data.id = S.editId;
google.script.run.withSuccessHandler(cb).withFailureHandler(eb).updateSurat(data);
} else {
google.script.run.withSuccessHandler(cb).withFailureHandler(eb).createSurat(data);
}
}
// ═══════════════════════════════════════════════
// DETAIL MODAL
// ═══════════════════════════════════════════════
function openDetail(id) {
const s = S.all.find(x => x.id === id);
if (!s) return;
S.detailId = id;
el('dtId').textContent = id;
const rows = [
['Jenis Surat', bJenis(s.jenis)],
['Status', bStatus(s.status)],
['Nomor Surat', `<span class="mono">${x(s.nomorSurat)}</span>`],
['Tanggal Surat', s.tanggalSurat],
['Dari / Kepada', x(s.dariKepada)],
['Klasifikasi', bKlasif(s.klasifikasi)],
['Perihal', x(s.perihal), true],
['Keterangan', x(s.keterangan)||'—', true],
['Dibuat Oleh', x(s.dibuatOleh)||'—'],
['Tanggal Input', s.tanggalInput],
['Terakhir Diubah',s.terakhirDiubah],
['File Lampiran', s.fileUrl ? `<a href="${x(s.fileUrl)}" target="_blank">Buka File ↗</a>` : '—', true],
];
el('dtContent').innerHTML = rows.map(([l,v,full]) =>
`<div class="${full?'detail-full':''}"><div class="dl">${l}</div><div class="dv">${v}</div></div>`
).join('');
openOv('ovDetail');
}
function editFromDetail() {
closeOv('ovDetail');
setTimeout(() => openEdit(S.detailId), 220);
}
// ═══════════════════════════════════════════════
// DELETE
// ═══════════════════════════════════════════════
function openDel(id) { S.deleteId = id; openOv('ovDel'); }
function doDelete() {
if (!S.deleteId) return;
const btn = el('btnDel');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Menghapus…';
google.script.run
.withSuccessHandler(r => {
btn.disabled = false; btn.innerHTML = '<i class="fa fa-trash"></i> Ya, Hapus';
if (r.success) { toast(r.message,'success'); closeOv('ovDel'); refreshAll(); }
else toast(r.message,'error');
})
.withFailureHandler(e => { btn.disabled=false; btn.innerHTML='<i class="fa fa-trash"></i> Ya, Hapus'; toast(e.message,'error'); })
.deleteSurat(S.deleteId);
}
// ═══════════════════════════════════════════════
// SETTINGS
// ═══════════════════════════════════════════════
function loadSettings() {
google.script.run
.withSuccessHandler(cfg => {
el('cfgId').value = cfg.sheetId || '';
el('cfgName').value = cfg.sheetName || '';
el('cfgOrg').value = cfg.orgName || '';
el('cfgLogo').value = cfg.orgLogo || '';
if (cfg.orgName) el('sbOrgName').textContent = cfg.orgName;
if (cfg.sheetId) testConn(true);
})
.getConfig();
}
function saveSettings() {
const cfg = {
sheetId : val('cfgId'),
sheetName: val('cfgName'),
orgName : val('cfgOrg'),
orgLogo : val('cfgLogo'),
};
google.script.run
.withSuccessHandler(r => {
if (r.success) {
toast(r.message,'success');
if (cfg.orgName) el('sbOrgName').textContent = cfg.orgName;
loadSettings();
} else toast(r.message,'error');
})
.withFailureHandler(e => toast(e.message,'error'))
.saveConfig(cfg);
}
function testConn(silent) {
if (!silent) toast('Menguji koneksi…','info');
google.script.run
.withSuccessHandler(r => {
const box = el('sheetInfoBox');
if (r.success) {
box.style.display = 'block';
el('siName').textContent = r.name;
el('siUrl').href = r.url;
el('siSheets').textContent = r.sheets.join(', ');
if (!silent) toast('Terhubung: ' + r.name,'success');
} else {
box.style.display = 'none';
if (!silent) toast(r.message,'error');
}
})
.withFailureHandler(e => { if(!silent) toast(e.message,'error'); })
.getSpreadsheetInfo();
}
function initSht() {
google.script.run
.withSuccessHandler(r => { if(r.success) toast('Sheet berhasil diinisialisasi','success'); else toast(r.message,'error'); })
.withFailureHandler(e => toast(e.message,'error'))
.initSheet();
}
function showSpanel(name) {
['sheet','org','about'].forEach(p => el('sp-'+p).style.display = p===name?'block':'none');
document.querySelectorAll('.snav-item').forEach((el,i)=>{
el.classList.toggle('active', ['sheet','org','about'][i]===name);
});
}
// ═══════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════
function doExportCsv() {
toast('Mempersiapkan ekspor CSV…','info');
google.script.run
.withSuccessHandler(r => {
if (!r.success) return toast(r.message,'error');
const b = new Blob([r.csv], { type:'text/csv;charset=utf-8;' });
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u; a.download = 'arsip-surat-' + today() + '.csv'; a.click();
URL.revokeObjectURL(u);
toast('Ekspor berhasil (' + S.all.length + ' baris)','success');
})
.withFailureHandler(e => toast(e.message,'error'))
.exportCsv();
}
// ═══════════════════════════════════════════════
// OVERLAY / MODAL HELPERS
// ═══════════════════════════════════════════════
function openOv(id) { el(id).classList.add('open'); }
function closeOv(id) { el(id).classList.remove('open'); }
document.querySelectorAll('.overlay').forEach(o => {
o.addEventListener('click', e => { if (e.target === o) closeOv(o.id); });
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.querySelectorAll('.overlay.open').forEach(o => closeOv(o.id));
});
// ═══════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════
function toast(msg, type='info') {
const icons = { success:'circle-check', error:'circle-xmark', warning:'triangle-exclamation', info:'circle-info' };
const tw = el('toastWrap');
const div = document.createElement('div');
div.className = `toast t-${type}`;
div.innerHTML = `<i class="fa fa-${icons[type]||'circle-info'}"></i><span>${msg}</span><i class="fa fa-xmark tx" onclick="this.parentElement.remove()"></i>`;
tw.appendChild(div);
setTimeout(()=>{ div.style.transition='opacity .3s'; div.style.opacity='0'; }, 3500);
setTimeout(()=>div.remove(), 3900);
}
// ═══════════════════════════════════════════════
// BADGE HELPERS
// ═══════════════════════════════════════════════
function bJenis(j) {
if (j==='Masuk') return `<span class="badge bm"><i class="fa fa-inbox fa-xs"></i>Masuk</span>`;
if (j==='Keluar') return `<span class="badge bk"><i class="fa fa-paper-plane fa-xs"></i>Keluar</span>`;
return `<span class="badge bb">${j||'—'}</span>`;
}
function bStatus(s) {
if (s==='Aktif') return `<span class="badge ba">Aktif</span>`;
if (s==='Diarsipkan') return `<span class="badge bar2">Diarsipkan</span>`;
if (s==='Dihapus') return `<span class="badge bh">Dihapus</span>`;
return `<span class="badge bb">${s||'—'}</span>`;
}
function bKlasif(k) {
if (k==='Rahasia'||k==='Sangat Rahasia') return `<span class="badge br">🔒${k}</span>`;
if (k==='Penting') return `<span class="badge bp">⚠️${k}</span>`;
return `<span class="badge bb">${k||'Biasa'}</span>`;
}
// ═══════════════════════════════════════════════
// UTILS
// ═══════════════════════════════════════════════
function el(id) { return document.getElementById(id); }
function set(id,v){ const e=el(id); if(e) e.textContent=v; }
function val(id) { return (el(id)?.value||'').trim(); }
function x(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
function today() { return new Date().toISOString().slice(0,10); }
function emptyRow(cols, title, sub) {
return `<tr><td colspan="${cols}">
<div class="empty-state">
<i class="fa fa-folder-open ei"></i>
<h3>${title}</h3><p>${sub}</p>
</div></td></tr>`;
}
// ═══════════════════════════════════════════════
// RESPONSIVE: detect mobile
// ═══════════════════════════════════════════════
function checkMobile() {
const mob = window.innerWidth < 769;
el('mobMenuBtn').style.display = mob ? 'flex' : 'none';
}
window.addEventListener('resize', checkMobile);
// ═══════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
checkMobile();
refreshAll();
});
</script>
</body>
</html>
********************************************* -->
<!DOCTYPE html>
<html>
<head>
<base target="_top">
</head>
<body>
<h2>Aplikasi Arsip Surat</h2>
</body>
</html>
Deployment
Setelah seluruh file selesai dibuat:
- Klik Deploy
- Pilih New Deployment
- Pilih Web App
- Execute As: Me
- Who Has Access: Anyone
- Klik Deploy
Sekarang aplikasi Arsip Surat Anda siap digunakan.

Posting Komentar untuk "Membuat Aplikasi Arsip Surat Dengan Google Apps Script"