/**
* KSL UjianKu VER 1
* Google Apps Script + Google Sheets CBT
* Optimized: batch reads, document cache, one-page app, no external libraries.
*/
const APP = {
VERSION: '1.0.0',
SHEETS: {
SETTINGS: 'PENGATURAN',
STUDENTS: 'PESERTA',
SUBJECTS: 'MAPEL',
QUESTIONS: 'BANK_SOAL',
EXAMS: 'UJIAN',
EXAM_QUESTIONS: 'UJIAN_SOAL',
SESSIONS: 'SESI_UJIAN',
ANSWERS: 'JAWABAN',
RESULTS: 'HASIL_UJIAN',
ADMINS: 'ADMIN',
AUDIT: 'AUDIT_LOG'
},
SESSION_TTL: 21600, // 6 jam
CACHE_TTL: 300
};
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle('KSL UjianKu')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1, viewport-fit=cover');
}
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
/**
* Jalankan sekali pada spreadsheet kosong/baru.
*/
function setupUjianKuVer1() {
const ss = SpreadsheetApp.getActive();
const specs = [
[APP.SHEETS.SETTINGS, ['KEY','VALUE','UPDATED_AT']],
[APP.SHEETS.STUDENTS, ['PESERTA_ID','NIS','NAMA','KELAS','USERNAME','PASSWORD_HASH','STATUS','CREATED_AT','UPDATED_AT']],
[APP.SHEETS.SUBJECTS, ['MAPEL_ID','KODE','NAMA_MAPEL','STATUS','CREATED_AT']],
[APP.SHEETS.QUESTIONS, ['SOAL_ID','MAPEL_ID','TIPE','PERTANYAAN','GAMBAR_URL','OPSI_A','OPSI_B','OPSI_C','OPSI_D','OPSI_E','KUNCI','BOBOT','STATUS','CREATED_AT','UPDATED_AT']],
[APP.SHEETS.EXAMS, ['UJIAN_ID','KODE_UJIAN','JUDUL','MAPEL_ID','KELAS_TARGET','MULAI_AT','SELESAI_AT','DURASI_MENIT','KKM','ACAK_SOAL','ACAK_OPSI','TAMPIL_NILAI','STATUS','CREATED_AT','UPDATED_AT']],
[APP.SHEETS.EXAM_QUESTIONS, ['UJIAN_ID','SOAL_ID','URUTAN']],
[APP.SHEETS.SESSIONS, ['SESSION_ID','UJIAN_ID','PESERTA_ID','START_AT','DEADLINE_AT','SUBMIT_AT','STATUS','QUESTION_ORDER_JSON','OPTION_ORDER_JSON','LAST_HEARTBEAT','CREATED_AT']],
[APP.SHEETS.ANSWERS, ['SESSION_ID','UJIAN_ID','PESERTA_ID','SOAL_ID','JAWABAN','RAGU','ANSWERED_AT']],
[APP.SHEETS.RESULTS, ['HASIL_ID','SESSION_ID','UJIAN_ID','PESERTA_ID','BENAR','SALAH','KOSONG','SKOR','LULUS','SUBMIT_AT']],
[APP.SHEETS.ADMINS, ['ADMIN_ID','USERNAME','PASSWORD_HASH','NAMA','ROLE','STATUS','CREATED_AT']],
[APP.SHEETS.AUDIT, ['LOG_ID','WAKTU','AKTOR_ID','AKTOR_NAMA','AKSI','OBJEK','DETAIL_JSON']]
];
specs.forEach(([name, headers]) => ensureSheet_(ss, name, headers));
seedSettings_();
seedDemoData_();
formatSheets_();
SpreadsheetApp.flush();
return {ok:true, message:'KSL UjianKu VER 1 siap digunakan.'};
}
function ensureSheet_(ss, name, headers) {
let sh = ss.getSheetByName(name);
if (!sh) sh = ss.insertSheet(name);
if (sh.getLastRow() === 0) {
sh.getRange(1,1,1,headers.length).setValues([headers]);
sh.setFrozenRows(1);
} else {
const existing = sh.getRange(1,1,1,headers.length).getValues()[0];
if (existing.join('|') !== headers.join('|')) {
sh.getRange(1,1,1,headers.length).setValues([headers]);
}
}
}
function seedSettings_() {
const sh = sh_(APP.SHEETS.SETTINGS);
if (sh.getLastRow() > 1) return;
const now = new Date();
const rows = [
['APP_NAME','KSL UjianKu',now],
['SCHOOL_NAME','SMA Kucing Storia',now],
['SCHOOL_SUBTITLE','Computer Based Test',now],
['PRIMARY_COLOR','#2563EB',now],
['ACCENT_COLOR','#7C3AED',now],
['LOGO_URL','',now],
['WELCOME_TEXT','Ujian digital yang cepat, fokus, dan nyaman digunakan.',now]
];
sh.getRange(2,1,rows.length,3).setValues(rows);
}
function seedDemoData_() {
const now = new Date();
const students = sh_(APP.SHEETS.STUDENTS);
if (students.getLastRow() === 1) {
const pwd = hash_('123456');
students.getRange(2,1,3,9).setValues([
['P001','2026001','Ahmad Fauzan','IX A','2026001',pwd,'AKTIF',now,now],
['P002','2026002','Aisyah Putri','IX A','2026002',pwd,'AKTIF',now,now],
['P003','2026003','Bima Pratama','IX A','2026003',pwd,'AKTIF',now,now]
]);
}
const subjects = sh_(APP.SHEETS.SUBJECTS);
if (subjects.getLastRow() === 1) {
subjects.getRange(2,1,2,5).setValues([
['M001','MTK','Matematika','AKTIF',now],
['M002','IPA','Ilmu Pengetahuan Alam','AKTIF',now]
]);
}
const questions = sh_(APP.SHEETS.QUESTIONS);
if (questions.getLastRow() === 1) {
const qs = [
['S001','M001','PG','Hasil dari 12 × 8 adalah...','','86','92','96','108','','C',1,'AKTIF',now,now],
['S002','M001','PG','Nilai dari 144 ÷ 12 adalah...','','10','11','12','14','','C',1,'AKTIF',now,now],
['S003','M001','PG','Jika x + 7 = 15, maka nilai x adalah...','','6','7','8','9','','C',1,'AKTIF',now,now],
['S004','M001','PG','Keliling persegi dengan sisi 9 cm adalah...','','18 cm','27 cm','36 cm','81 cm','','C',1,'AKTIF',now,now],
['S005','M001','PG','25% dari 200 adalah...','','25','40','50','75','','C',1,'AKTIF',now,now],
['S006','M001','PG','Bilangan prima di bawah ini adalah...','','21','27','29','33','','C',1,'AKTIF',now,now],
['S007','M001','PG','Hasil 3² + 4² adalah...','','7','12','25','49','','C',1,'AKTIF',now,now],
['S008','M001','PG','Bentuk desimal dari 3/4 adalah...','','0,25','0,5','0,75','1,25','','C',1,'AKTIF',now,now],
['S009','M001','PG','Rata-rata dari 6, 8, dan 10 adalah...','','7','8','9','10','','B',1,'AKTIF',now,now],
['S010','M001','PG','Luas segitiga dengan alas 10 cm dan tinggi 8 cm adalah...','','18 cm²','40 cm²','80 cm²','90 cm²','','B',1,'AKTIF',now,now]
];
questions.getRange(2,1,qs.length,15).setValues(qs);
}
const exams = sh_(APP.SHEETS.EXAMS);
if (exams.getLastRow() === 1) {
const start = new Date(now.getTime() - 24*60*60*1000);
const end = new Date(now.getTime() + 30*24*60*60*1000);
exams.getRange(2,1,1,15).setValues([[
'U001','MTK-IXA-01','Ujian Matematika Dasar','M001','IX A',
start,end,60,75,true,true,true,'AKTIF',now,now
]]);
}
const eq = sh_(APP.SHEETS.EXAM_QUESTIONS);
if (eq.getLastRow() === 1) {
const rows = Array.from({length:10}, (_,i)=>['U001','S'+String(i+1).padStart(3,'0'),i+1]);
eq.getRange(2,1,rows.length,3).setValues(rows);
}
const admins = sh_(APP.SHEETS.ADMINS);
if (admins.getLastRow() === 1) {
admins.getRange(2,1,1,7).setValues([[
'A001','admin',hash_('admin123'),'Administrator','SUPERADMIN','AKTIF',now
]]);
}
}
/* =========================
PUBLIC API
========================= */
function getPublicBootstrap() {
const settings = getSettings_();
return {
ok: true,
version: APP.VERSION,
brand: {
appName: settings.APP_NAME || 'KSL UjianKu',
schoolName: settings.SCHOOL_NAME || '',
subtitle: settings.SCHOOL_SUBTITLE || '',
primary: settings.PRIMARY_COLOR || '#2563EB',
accent: settings.ACCENT_COLOR || '#7C3AED',
logoUrl: settings.LOGO_URL || '',
welcomeText: settings.WELCOME_TEXT || ''
}
};
}
function loginPeserta(payload) {
payload = payload || {};
const username = clean_(payload.username, 80);
const password = String(payload.password || '');
if (!username || !password) throw new Error('Username dan password wajib diisi.');
const rows = dataObjects_(APP.SHEETS.STUDENTS);
const p = rows.find(x =>
String(x.USERNAME).trim() === username &&
String(x.STATUS).toUpperCase() === 'AKTIF' &&
safeEq_(String(x.PASSWORD_HASH), hash_(password))
);
if (!p) throw new Error('Username atau password tidak sesuai.');
const token = Utilities.getUuid().replace(/-/g,'');
CacheService.getScriptCache().put('auth:'+token, JSON.stringify({
pesertaId:p.PESERTA_ID, nama:p.NAMA, kelas:p.KELAS, nis:p.NIS, type:'peserta'
}), APP.SESSION_TTL);
audit_(p.PESERTA_ID, p.NAMA, 'LOGIN', 'PESERTA', {kelas:p.KELAS});
return {ok:true, token, user:{id:p.PESERTA_ID,nama:p.NAMA,kelas:p.KELAS,nis:p.NIS}};
}
function getDashboardPeserta(token) {
const auth = auth_(token, 'peserta');
const now = new Date();
const exams = dataObjects_(APP.SHEETS.EXAMS)
.filter(x => String(x.STATUS).toUpperCase()==='AKTIF')
.filter(x => !x.KELAS_TARGET || String(x.KELAS_TARGET).trim() === String(auth.kelas).trim())
.map(x => {
const start = toDate_(x.MULAI_AT), end = toDate_(x.SELESAI_AT);
let state = 'TERSEDIA';
if (start && now < start) state = 'TERJADWAL';
if (end && now > end) state = 'SELESAI';
const result = findResult_(x.UJIAN_ID, auth.pesertaId);
if (result) state = 'SUDAH_DIKERJAKAN';
return {
ujianId:x.UJIAN_ID,
kode:x.KODE_UJIAN,
judul:x.JUDUL,
mapelId:x.MAPEL_ID,
mulai:iso_(start),
selesai:iso_(end),
durasi:Number(x.DURASI_MENIT||0),
kkm:Number(x.KKM||0),
tampilNilai:bool_(x.TAMPIL_NILAI),
state,
result: result ? {skor:Number(result.SKOR||0), lulus:String(result.LULUS)==='YA'} : null
};
});
return {ok:true, user:auth, exams};
}
function startExam(token, ujianId) {
const auth = auth_(token, 'peserta');
const exam = dataObjects_(APP.SHEETS.EXAMS).find(x => String(x.UJIAN_ID) === String(ujianId));
if (!exam || String(exam.STATUS).toUpperCase() !== 'AKTIF') throw new Error('Ujian tidak tersedia.');
const now = new Date();
const startAt = toDate_(exam.MULAI_AT), endAt = toDate_(exam.SELESAI_AT);
if (startAt && now < startAt) throw new Error('Ujian belum dimulai.');
if (endAt && now > endAt) throw new Error('Periode ujian telah berakhir.');
if (exam.KELAS_TARGET && String(exam.KELAS_TARGET).trim() !== String(auth.kelas).trim()) {
throw new Error('Ujian tidak tersedia untuk kelas Anda.');
}
if (findResult_(ujianId, auth.pesertaId)) throw new Error('Ujian ini sudah pernah diselesaikan.');
let session = findOpenSession_(ujianId, auth.pesertaId);
if (!session) {
session = createSession_(exam, auth);
}
const deadline = toDate_(session.DEADLINE_AT);
if (deadline && now >= deadline) {
finalizeSession_(session.SESSION_ID);
throw new Error('Waktu ujian telah habis.');
}
const pack = buildQuestionPack_(exam, session);
const saved = getAnswersMap_(session.SESSION_ID);
return {
ok:true,
sessionId:session.SESSION_ID,
exam:{
ujianId:exam.UJIAN_ID,
judul:exam.JUDUL,
kode:exam.KODE_UJIAN,
durasi:Number(exam.DURASI_MENIT||0),
deadline:iso_(deadline),
kkm:Number(exam.KKM||0)
},
questions:pack,
saved
};
}
function saveAnswer(token, payload) {
const auth = auth_(token, 'peserta');
payload = payload || {};
const session = getSession_(payload.sessionId);
validateSessionOwner_(session, auth);
if (String(session.STATUS) !== 'AKTIF') throw new Error('Sesi ujian sudah ditutup.');
if (new Date() >= toDate_(session.DEADLINE_AT)) {
finalizeSession_(session.SESSION_ID);
throw new Error('Waktu ujian telah habis.');
}
const soalId = clean_(payload.soalId, 60);
const jawaban = clean_(payload.jawaban, 5).toUpperCase();
const ragu = !!payload.ragu;
if (!soalId) throw new Error('Soal tidak valid.');
if (jawaban && !['A','B','C','D','E'].includes(jawaban)) throw new Error('Jawaban tidak valid.');
upsertAnswer_(session, auth, soalId, jawaban, ragu);
touchSession_(session.SESSION_ID);
return {ok:true, savedAt:iso_(new Date())};
}
function submitExam(token, sessionId) {
const auth = auth_(token, 'peserta');
const session = getSession_(sessionId);
validateSessionOwner_(session, auth);
const result = finalizeSession_(sessionId);
const exam = dataObjects_(APP.SHEETS.EXAMS).find(x => String(x.UJIAN_ID) === String(session.UJIAN_ID));
return {
ok:true,
result: {
benar:result.BENAR,
salah:result.SALAH,
kosong:result.KOSONG,
skor:result.SKOR,
lulus:result.LULUS === 'YA',
showScore: exam ? bool_(exam.TAMPIL_NILAI) : true
}
};
}
/* =========================
EXAM ENGINE
========================= */
function createSession_(exam, auth) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const existing = findOpenSession_(exam.UJIAN_ID, auth.pesertaId);
if (existing) return existing;
const eq = dataObjects_(APP.SHEETS.EXAM_QUESTIONS)
.filter(x => String(x.UJIAN_ID) === String(exam.UJIAN_ID))
.sort((a,b)=>Number(a.URUTAN||0)-Number(b.URUTAN||0));
let order = eq.map(x=>String(x.SOAL_ID));
if (bool_(exam.ACAK_SOAL)) shuffle_(order);
const questions = questionMap_();
const optionOrder = {};
order.forEach(id => {
const q = questions[id];
const keys = ['A','B','C','D','E'].filter(k => String(q && q['OPSI_'+k] || '').trim());
if (bool_(exam.ACAK_OPSI)) shuffle_(keys);
optionOrder[id] = keys;
});
const now = new Date();
const durationEnd = new Date(now.getTime() + Number(exam.DURASI_MENIT||0)*60000);
const examEnd = toDate_(exam.SELESAI_AT);
const deadline = examEnd && examEnd < durationEnd ? examEnd : durationEnd;
const sessionId = 'SES-' + Utilities.getUuid().slice(0,8).toUpperCase();
const row = [
sessionId, exam.UJIAN_ID, auth.pesertaId, now, deadline, '',
'AKTIF', JSON.stringify(order), JSON.stringify(optionOrder), now, now
];
sh_(APP.SHEETS.SESSIONS).appendRow(row);
audit_(auth.pesertaId, auth.nama, 'START_EXAM', exam.UJIAN_ID, {sessionId});
return objectFromHeaders_(headers_(APP.SHEETS.SESSIONS), row);
} finally {
lock.releaseLock();
}
}
function buildQuestionPack_(exam, session) {
const qmap = questionMap_();
const order = parseJson_(session.QUESTION_ORDER_JSON, []);
const optionOrder = parseJson_(session.OPTION_ORDER_JSON, {});
return order.map((id, idx) => {
const q = qmap[id];
if (!q) return null;
const opts = {};
(optionOrder[id] || ['A','B','C','D','E']).forEach(k => {
const text = q['OPSI_'+k];
if (String(text||'').trim()) opts[k] = String(text);
});
return {
no:idx+1,
soalId:String(q.SOAL_ID),
pertanyaan:String(q.PERTANYAAN||''),
gambarUrl:String(q.GAMBAR_URL||''),
options:opts
};
}).filter(Boolean);
}
function finalizeSession_(sessionId) {
const lock = LockService.getScriptLock();
lock.waitLock(15000);
try {
const session = getSession_(sessionId);
if (!session) throw new Error('Sesi tidak ditemukan.');
const existing = dataObjects_(APP.SHEETS.RESULTS).find(x => String(x.SESSION_ID) === String(sessionId));
if (existing) return normalizeResult_(existing);
const qmap = questionMap_();
const order = parseJson_(session.QUESTION_ORDER_JSON, []);
const answers = getAnswersMap_(sessionId);
let benar=0, salah=0, kosong=0, earned=0, total=0;
order.forEach(id => {
const q = qmap[id];
if (!q) return;
const weight = Number(q.BOBOT || 1);
total += weight;
const ans = answers[id] && answers[id].jawaban ? String(answers[id].jawaban).toUpperCase() : '';
if (!ans) kosong++;
else if (ans === String(q.KUNCI||'').toUpperCase()) { benar++; earned += weight; }
else salah++;
});
const score = total ? Math.round((earned/total)*10000)/100 : 0;
const exam = dataObjects_(APP.SHEETS.EXAMS).find(x => String(x.UJIAN_ID) === String(session.UJIAN_ID));
const kkm = Number(exam && exam.KKM || 0);
const submitAt = new Date();
const result = {
HASIL_ID:'HSL-' + Utilities.getUuid().slice(0,8).toUpperCase(),
SESSION_ID:sessionId,
UJIAN_ID:session.UJIAN_ID,
PESERTA_ID:session.PESERTA_ID,
BENAR:benar, SALAH:salah, KOSONG:kosong,
SKOR:score, LULUS:score>=kkm?'YA':'TIDAK', SUBMIT_AT:submitAt
};
sh_(APP.SHEETS.RESULTS).appendRow([
result.HASIL_ID,result.SESSION_ID,result.UJIAN_ID,result.PESERTA_ID,
result.BENAR,result.SALAH,result.KOSONG,result.SKOR,result.LULUS,result.SUBMIT_AT
]);
updateSessionStatus_(sessionId, 'SELESAI', submitAt);
audit_(session.PESERTA_ID, '', 'SUBMIT_EXAM', session.UJIAN_ID, {sessionId, skor:score});
return result;
} finally {
lock.releaseLock();
}
}
function upsertAnswer_(session, auth, soalId, jawaban, ragu) {
const sh = sh_(APP.SHEETS.ANSWERS);
const values = sh.getDataRange().getValues();
const h = values[0];
const iSession=h.indexOf('SESSION_ID'), iSoal=h.indexOf('SOAL_ID');
for (let r=1; r
String(x.SESSION_ID)===String(sessionId));
const out={};
rows.forEach(x => out[String(x.SOAL_ID)] = {
jawaban:String(x.JAWABAN||''), ragu:String(x.RAGU)==='YA'
});
return out;
}
/* =========================
HELPERS
========================= */
function auth_(token, expectedType) {
token = clean_(token, 128);
if (!token) throw new Error('Sesi login tidak ditemukan.');
const raw = CacheService.getScriptCache().get('auth:'+token);
if (!raw) throw new Error('Sesi login telah berakhir. Silakan login kembali.');
const data = JSON.parse(raw);
if (expectedType && data.type !== expectedType) throw new Error('Akses tidak diizinkan.');
CacheService.getScriptCache().put('auth:'+token, JSON.stringify(data), APP.SESSION_TTL);
return data;
}
function getSettings_() {
const cache = CacheService.getDocumentCache();
const key = 'settings:v1';
const cached = cache.get(key);
if (cached) return JSON.parse(cached);
const rows = dataObjects_(APP.SHEETS.SETTINGS);
const obj={};
rows.forEach(x=>obj[String(x.KEY)]=x.VALUE);
cache.put(key, JSON.stringify(obj), APP.CACHE_TTL);
return obj;
}
function questionMap_() {
const cache = CacheService.getDocumentCache();
const key = 'questions:active:v1';
const cached = cache.get(key);
if (cached) return JSON.parse(cached);
const rows = dataObjects_(APP.SHEETS.QUESTIONS).filter(x=>String(x.STATUS).toUpperCase()==='AKTIF');
const map={};
rows.forEach(x=>map[String(x.SOAL_ID)]=x);
cache.put(key, JSON.stringify(map), 120);
return map;
}
function findOpenSession_(ujianId, pesertaId) {
return dataObjects_(APP.SHEETS.SESSIONS).find(x =>
String(x.UJIAN_ID)===String(ujianId) &&
String(x.PESERTA_ID)===String(pesertaId) &&
String(x.STATUS)==='AKTIF'
) || null;
}
function getSession_(sessionId) {
return dataObjects_(APP.SHEETS.SESSIONS).find(x=>String(x.SESSION_ID)===String(sessionId)) || null;
}
function validateSessionOwner_(session, auth) {
if (!session) throw new Error('Sesi ujian tidak ditemukan.');
if (String(session.PESERTA_ID)!==String(auth.pesertaId)) throw new Error('Sesi ujian tidak valid.');
}
function touchSession_(sessionId) {
const sh=sh_(APP.SHEETS.SESSIONS), data=sh.getDataRange().getValues(), h=data[0];
const idc=h.indexOf('SESSION_ID'), hb=h.indexOf('LAST_HEARTBEAT');
for(let r=1;r
String(x.UJIAN_ID)===String(ujianId) && String(x.PESERTA_ID)===String(pesertaId)
) || null;
}
function normalizeResult_(x){
return {
HASIL_ID:x.HASIL_ID, SESSION_ID:x.SESSION_ID, UJIAN_ID:x.UJIAN_ID, PESERTA_ID:x.PESERTA_ID,
BENAR:Number(x.BENAR||0), SALAH:Number(x.SALAH||0), KOSONG:Number(x.KOSONG||0),
SKOR:Number(x.SKOR||0), LULUS:String(x.LULUS), SUBMIT_AT:x.SUBMIT_AT
};
}
function dataObjects_(sheetName) {
const sh=sh_(sheetName), values=sh.getDataRange().getValues();
if(values.length<2) return [];
const headers=values[0].map(String);
return values.slice(1).filter(r=>r.some(v=>v!=='' && v!==null)).map(r=>objectFromHeaders_(headers,r));
}
function objectFromHeaders_(headers,row) {
const o={}; headers.forEach((h,i)=>o[h]=row[i]); return o;
}
function headers_(name){ return sh_(name).getRange(1,1,1,sh_(name).getLastColumn()).getValues()[0]; }
function sh_(name){ const s=SpreadsheetApp.getActive().getSheetByName(name); if(!s) throw new Error('Sheet '+name+' belum ada. Jalankan setupUjianKuVer1().'); return s; }
function parseJson_(s,fallback){ try{return JSON.parse(String(s||''))}catch(e){return fallback} }
function bool_(v){ return v===true || String(v).toUpperCase()==='TRUE' || String(v)==='1' || String(v).toUpperCase()==='YA'; }
function iso_(d){ return d instanceof Date && !isNaN(d) ? d.toISOString() : ''; }
function toDate_(v){ if(v instanceof Date) return v; if(!v) return null; const d=new Date(v); return isNaN(d)?null:d; }
function clean_(v,max){ return String(v==null?'':v).trim().slice(0,max||500); }
function safeEq_(a,b){ if(a.length!==b.length)return false; let x=0; for(let i=0;i('0'+(b&255).toString(16)).slice(-2)).join(''); }
function shuffle_(a){ for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } return a; }
function audit_(actorId, actorName, action, object, detail) {
try {
sh_(APP.SHEETS.AUDIT).appendRow([
'LOG-'+Utilities.getUuid().slice(0,8).toUpperCase(), new Date(),
actorId||'',actorName||'',action||'',object||'',JSON.stringify(detail||{})
]);
} catch(e) {}
}
function formatSheets_() {
const ss=SpreadsheetApp.getActive();
Object.values(APP.SHEETS).forEach(name=>{
const sh=ss.getSheetByName(name);
if(!sh)return;
const lc=sh.getLastColumn();
if(lc){
sh.getRange(1,1,1,lc).setFontWeight('bold').setBackground('#0F172A').setFontColor('#FFFFFF');
sh.autoResizeColumns(1,lc);
}
});
}
● KSL DIGITAL EXAM PLATFORM
Ujian digital yang terasa modern.
Cepat, fokus, dan nyaman digunakan di laptop maupun ponsel.
RinganTanpa framework eksternal
RealtimeJawaban tersimpan otomatis
AmanKunci jawaban tetap di server
UJIAN TERSEDIA0
SUDAH SELESAI0
RATA-RATA NILAI—
STATUS PESERTAAktif
Daftar Ujian
UJIAN SELESAI
0
Komentar
Posting Komentar
Terimakasih telah memberikan komentarnya