import React, { useState, useEffect } from 'react';
import { Download, Plus, Trash2, Send, FileSpreadsheet, ClipboardCheck, BrainCircuit, User, School, GraduationCap, Gauge, BookOpen, Image as ImageIcon, Sparkles, LayoutList, CheckSquare, AlignJustify, ToggleLeft, FileText, Bookmark, Printer, FileText as FileTextIcon, Bot, Target, AlertTriangle, ThumbsUp, Edit2, LayoutGrid, ListOrdered } from 'lucide-react';
// --- HELPER UNTUK FETCH API DENGAN EXPONENTIAL BACKOFF ---
const fetchWithRetry = async (url, options, retries = 5) => {
let delay = 1000;
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) {
return response;
}
// Jika ini adalah percobaan terakhir, lempar error sesuai status HTTP
if (i === retries - 1) {
if (response.status === 401 || response.status === 403) {
throw new Error(`Autentikasi API ditolak (Status: ${response.status}). Pastikan API Key Anda dikonfigurasi dengan benar.`);
}
throw new Error(`Server AI sibuk atau menolak permintaan (Status: ${response.status}).`);
}
} catch (error) {
if (i === retries - 1) throw error;
}
// Tunggu dengan jeda yang meningkat eksponensial (1s, 2s, 4s, 8s, 16s)
await new Promise(res => setTimeout(res, delay));
delay *= 2;
}
};
// --- HELPER UNTUK MERENDER GAMBAR & TEKS ARAB (RTL) ---
const renderText = (text) => {
if (!text || typeof text !== 'string') return text;
// Pisahkan string berdasarkan tag [GAMBAR:...] dan [ARABIC]...[/ARABIC]
const parts = text.split(/(\[GAMBAR:.*?\]|\[ARABIC\][\s\S]*?\[\/ARABIC\])/g);
return parts.map((part, index) => {
if (part.startsWith('[GAMBAR:')) {
return (
{part}
(Ganti kotak ini dengan gambar yang sesuai di MS Word)
);
} else if (part.startsWith('[ARABIC]')) {
const arabicText = part.replace('[ARABIC]', '').replace('[/ARABIC]', '');
return (
{arabicText}
);
} else {
// Render teks biasa, dukung enter (\n)
return {part.split('\n').map((line, i) => {line}{i !== part.split('\n').length - 1 &&
})};
}
});
};
// --- HELPER FORMAT EXPORT WORD & PDF ---
const processExportText = (t) => {
if (!t) return '';
let str = String(t).replace(/\n/g, '
');
str = str.replace(/\[GAMBAR:(.*?)\]/g, '[Tempat Gambar: $1]
');
str = str.replace(/\[ARABIC\]([\s\S]*?)\[\/ARABIC\]/g, '$1
');
return str;
};
// --- HELPER FORMAT KUNCI JAWABAN (ARRAY/OBJECT KE TEKS) ---
const formatKunciJawaban = (kunci, separator = ', ') => {
if (kunci === null || kunci === undefined) return '-';
if (typeof kunci === 'string' || typeof kunci === 'number') return String(kunci);
// Jika AI merespons Kunci berupa Array (cth: soal Benar-Salah)
if (Array.isArray(kunci)) {
return kunci.map((item, index) => {
if (typeof item === 'object') return `${index + 1}. ${item.jawaban || item.kunci || JSON.stringify(item)}`;
return `${index + 1}. ${item}`;
}).join(separator);
}
// Jika AI merespons Kunci berupa Object (cth: Menjodohkan / Kategori)
if (typeof kunci === 'object') {
return Object.entries(kunci).map(([k, v]) => `${k}: ${v}`).join(separator);
}
return String(kunci);
};
// --- KOMPONEN RENDER SOAL (WEB PREVIEW) ---
const RenderSoal = ({ soal }) => {
if (!soal || typeof soal !== 'object') return null;
try {
switch (soal.bentuk) {
case 'Pilihan Ganda':
case 'PG':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
{soal.opsi && typeof soal.opsi === 'object' && !Array.isArray(soal.opsi) ? (
Object.entries(soal.opsi).map(([key, value]) => (
{key}.
{renderText(String(value))}
))
) : (
Error: Format opsi PG harus object (A: ..., B: ...)
)}
);
case 'PG Kompleks':
case 'Pilihan Ganda Kompleks':
const opsiPGK = Array.isArray(soal.opsi) ? soal.opsi : (typeof soal.opsi === 'object' && soal.opsi !== null ? Object.values(soal.opsi) : []);
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
{opsiPGK.length > 0 ? (
opsiPGK.map((opsi, idx) => (
{renderText(typeof opsi === 'object' ? opsi.teks : opsi)}
))
) : (
Error: Format opsi PG Kompleks tidak valid
)}
);
case 'Menjodohkan':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
{/* Kolom Kiri */}
{/* Kolom Kanan */}
);
case 'Benar Salah':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
| No |
Pernyataan |
Benar |
Salah |
{Array.isArray(soal.pernyataan) && soal.pernyataan.map((item, idx) => (
| {idx + 1} |
{renderText(typeof item === 'object' ? item.teks : item)} |
|
|
))}
);
case 'Setuju-Tidak Setuju':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
| No |
Pernyataan |
Setuju |
Tidak Setuju |
{Array.isArray(soal.pernyataan) && soal.pernyataan.map((item, idx) => (
| {idx + 1} |
{renderText(typeof item === 'object' ? item.teks : item)} |
|
|
))}
);
case 'Jawaban Singkat':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
);
case 'Isian Singkat':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
(1 kata)
);
case 'Uraian/ Essay':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
);
case 'Mengkategorikan':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
{soal.kategori && soal.item_kategori ? (
| Item / Pernyataan |
{soal.kategori.map((kat, kIdx) => (
{kat} |
))}
{soal.item_kategori.map((item, idx) => (
| {renderText(item)} |
{soal.kategori.map((_, kIdx) => (
✔ |
))}
))}
) :
Error: Format kategori tidak sesuai.
}
);
case 'Mengurutkan/ Menyusun':
return (
{soal.stimulus_content &&
{renderText(soal.stimulus_content)}
}
{renderText(soal.pertanyaan)}
{Array.isArray(soal.item_acak) && soal.item_acak.map((item, idx) => (
( ... )
{renderText(item)}
))}
);
default:
return Format soal {soal.bentuk} tidak dikenali
;
}
} catch (err) {
return Gagal merender soal ini. Data tidak valid.
;
}
};
const App = () => {
const [loading, setLoading] = useState(false);
const [loadingProgress, setLoadingProgress] = useState('');
const [loadingMateri, setLoadingMateri] = useState(false);
const [loadingTP, setLoadingTP] = useState(false);
const [config, setConfig] = useState({
sekolah: 'SMP Negeri 3 Cikarang Timur',
tingkatSekolah: 'SMP',
mapel: 'Pendidikan Agama Islam dan Budi Pekerti',
tp: '',
bab: '',
subBab: '',
materiLengkap: '',
namaGuru: 'Asep Saefullah',
kelas: 'Kelas 9 (Fase D)',
kurikulum: 'Kurikulum Merdeka',
jumlahPerBentuk: { 'Pilihan Ganda': 10 },
levelKognitif: { L1: 3, L2: 4, L3: 3 },
jumlahGambar: 0,
});
const [generatedData, setGeneratedData] = useState(null);
const [activeTab, setActiveTab] = useState('input');
const [errorMsg, setErrorMsg] = useState('');
const apiKey = "";
// --- SETUP AWAL & PROTEKSI COPY/KLIK KANAN ---
useEffect(() => {
// 1. Injeksi Tailwind CSS CDN agar tampilan tetap rapi saat di-deploy ke Netlify/Vercel
if (!document.getElementById('tailwind-cdn-script')) {
const script = document.createElement('script');
script.id = 'tailwind-cdn-script';
script.src = 'https://cdn.tailwindcss.com';
document.head.appendChild(script);
}
// 2. Mencegah aksi klik kanan dan copy-paste
const preventAction = (e) => {
// Izinkan aksi pada input dan textarea agar form tetap bisa digunakan untuk mengetik & menempel teks
const isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
if (!isInput) {
e.preventDefault();
}
};
// Mencegah klik kanan (Context Menu)
document.addEventListener('contextmenu', preventAction);
// Mencegah Copy dan Cut dari keyboard
document.addEventListener('copy', preventAction);
document.addEventListener('cut', preventAction);
// Mencegah drag/tarik elemen (teks/gambar)
document.addEventListener('dragstart', preventAction);
return () => {
document.removeEventListener('contextmenu', preventAction);
document.removeEventListener('copy', preventAction);
document.removeEventListener('cut', preventAction);
document.removeEventListener('dragstart', preventAction);
};
}, []);
const getKelasOptions = (tingkat) => {
switch (tingkat) {
case 'SD': return ["Kelas 1 (Fase A)", "Kelas 2 (Fase A)", "Kelas 3 (Fase B)", "Kelas 4 (Fase B)", "Kelas 5 (Fase C)", "Kelas 6 (Fase C)"];
case 'SMP': return ["Kelas 7 (Fase D)", "Kelas 8 (Fase D)", "Kelas 9 (Fase D)"];
case 'SMA':
case 'SMK': return ["Kelas 10 (Fase E)", "Kelas 11 (Fase F)", "Kelas 12 (Fase F)"];
default: return ["Pilih Jenjang Dulu"];
}
};
const handleTingkatChange = (e) => {
const newTingkat = e.target.value;
const options = getKelasOptions(newTingkat);
setConfig(prev => ({ ...prev, tingkatSekolah: newTingkat, kelas: options[0] }));
};
const totalSoal = Object.values(config.jumlahPerBentuk).reduce((acc, curr) => acc + (parseInt(curr) || 0), 0);
const totalLevel = (parseInt(config.levelKognitif.L1) || 0) + (parseInt(config.levelKognitif.L2) || 0) + (parseInt(config.levelKognitif.L3) || 0);
const isConfigValid = totalSoal > 0 && totalSoal <= 100 && totalSoal === totalLevel;
const handleBentukChange = (type, value) => {
const val = parseInt(value);
setConfig(prev => {
const newBentuk = { ...prev.jumlahPerBentuk };
if (isNaN(val) || val <= 0) {
delete newBentuk[type];
} else {
newBentuk[type] = val;
}
return { ...prev, jumlahPerBentuk: newBentuk };
});
};
const generateTPOtomatis = async () => {
if (!config.mapel || !config.bab) { alert("Mohon isi 'Mata Pelajaran' dan 'BAB' terlebih dahulu."); return; }
setLoadingTP(true);
const userQuery = `Rumuskan Tujuan Pembelajaran (TP) yang spesifik dan terukur dalam 1-2 kalimat pendek. Konteks: Jenjang ${config.tingkatSekolah}, Mapel ${config.mapel}, ${config.kelas}, BAB ${config.bab}, Topik ${config.subBab}. Materi Referensi (Wajib Relevan): """${config.materiLengkap || "Sesuai standar kurikulum"}""" Instruksi: Gunakan KKO sesuai taksonomi Bloom. TP HARUS mencerminkan materi referensi di atas. Hapus simbol markdown (*, #).`;
try {
const response = await fetchWithRetry(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: userQuery }] }] })
});
const data = await response.json();
if (data.candidates?.[0]?.content?.parts?.[0]?.text) setConfig(prev => ({ ...prev, tp: data.candidates[0].content.parts[0].text.replace(/[*#]/g, '').trim() }));
} catch (error) { console.error(error); alert("Gagal generate TP: " + error.message); } finally { setLoadingTP(false); }
};
const generateMateriOtomatis = async () => {
if (!config.mapel || !config.bab) { alert("Mohon isi 'Mata Pelajaran' dan 'BAB' terlebih dahulu."); return; }
setLoadingMateri(true);
const userQuery = `Buatkan ringkasan materi pelajaran SANGAT PADAT (Max 150 kata). Identitas: ${config.tingkatSekolah}, ${config.mapel}, ${config.kelas}, BAB ${config.bab}, Sub ${config.subBab}. Instruksi: Paragraf narasi biasa. HAPUS semua markdown (*, #, -). Hapus enter berlebih.`;
try {
const response = await fetchWithRetry(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: userQuery }] }] })
});
const data = await response.json();
if (data.candidates?.[0]?.content?.parts?.[0]?.text) setConfig(prev => ({ ...prev, materiLengkap: data.candidates[0].content.parts[0].text.replace(/[*#]/g, '').replace(/\n\s*\n/g, '\n').trim() }));
} catch (error) { console.error(error); alert("Gagal generate materi: " + error.message); } finally { setLoadingMateri(false); }
};
const generateContent = async () => {
setLoading(true);
setErrorMsg('');
setGeneratedData(null);
setLoadingProgress('Mempersiapkan data...');
// 1. CHUNKING LOGIC UNTUK MENGHINDARI OUTPUT LIMIT
// Membagi jumlah soal menjadi potongan per 5 soal agar AI tidak memotong JSON
const chunks = [];
let currentChunk = {};
let currentCount = 0;
for (const [bentuk, jumlah] of Object.entries(config.jumlahPerBentuk)) {
let remaining = parseInt(jumlah) || 0;
while (remaining > 0) {
// Mengurangi jumlah soal per request dari 10 menjadi 5 untuk mencegah pemotongan JSON
const take = Math.min(remaining, 5 - currentCount);
currentChunk[bentuk] = (currentChunk[bentuk] || 0) + take;
currentCount += take;
remaining -= take;
if (currentCount >= 5) {
chunks.push(currentChunk);
currentChunk = {};
currentCount = 0;
}
}
}
if (currentCount > 0) chunks.push(currentChunk);
// 2. DISTRIBUSI LEVEL KOGNITIF & GAMBAR SECARA PROPORSIONAL PER CHUNK
let remainingL1 = parseInt(config.levelKognitif.L1) || 0;
let remainingL2 = parseInt(config.levelKognitif.L2) || 0;
let remainingL3 = parseInt(config.levelKognitif.L3) || 0;
let remainingGambar = parseInt(config.jumlahGambar) || 0;
const chunkData = chunks.map(chunk => {
const totalChunk = Object.values(chunk).reduce((a, b) => a + b, 0);
let cL1 = 0, cL2 = 0, cL3 = 0, cGambar = 0;
for (let i = 0; i < totalChunk; i++) {
if (remainingL1 > 0) { cL1++; remainingL1--; }
else if (remainingL2 > 0) { cL2++; remainingL2--; }
else if (remainingL3 > 0) { cL3++; remainingL3--; }
}
const takeImg = Math.min(remainingGambar, totalChunk);
cGambar = takeImg;
remainingGambar -= takeImg;
return { bentuk: chunk, total: totalChunk, L1: cL1, L2: cL2, L3: cL3, gambar: cGambar };
});
let allKisi = [];
let allSoal = [];
try {
// PROSES SETIAP CHUNK SECARA BERURUTAN
for (let i = 0; i < chunkData.length; i++) {
const c = chunkData[i];
setLoadingProgress(`Meracik Soal: Bagian ${i + 1} dari ${chunkData.length} ...`);
const bentukString = Object.entries(c.bentuk).map(([k, v]) => `${v} soal ${k}`).join(', ');
const cLevelString = `L1 (Pemahaman/C1-C2): ${c.L1} soal, L2 (Aplikasi/C3): ${c.L2} soal, L3 (Penalaran/HOTS/C4-C6): ${c.L3} soal`;
const systemPrompt = `Anda adalah pakar evaluasi pendidikan jenjang ${config.tingkatSekolah}.
Tugas: Susun Kisi-kisi & Soal yang valid.
REFERENSI UTAMA: """${config.materiLengkap || "Gunakan pengetahuan umum kurikulum yang sesuai."}"""
ATURAN KONTEN SANGAT PENTING:
1. **STIMULUS LITERASI & NUMERASI (WAJIB)**: SETIAP soal TANPA TERKECUALI WAJIB memiliki 'stimulus_content'. Stimulus harus menuntut siswa untuk berliterasi (wacana/artikel/kasus) atau bernumerasi (tabel/angka/data).
2. **Pertanyaan**: Harus terkait logis dengan stimulus yang diberikan.
3. **Distribusi Level Kognitif**: Sesuaikan persis dengan target: ${cLevelString}.
4. **Menjodohkan**: Gunakan format dua lajur. Lajur Kiri berisi premis, Lajur Kanan berisi pasangan.
5. **Gambar**: Buat ${c.gambar} soal yang WAJIB memiliki stimulus berupa deskripsi gambar (Gunakan format: [GAMBAR: Deskripsi detil visual gambar...]).
6. **Kunci Jawaban**: PG cukup huruf kunci. Menjodohkan pasangkan kode.
7. **Format Tambahan Khusus**:
- Setuju-Tidak Setuju / Benar Salah: Gunakan array "pernyataan".
- Mengkategorikan: Gunakan array "kategori" dan "item_kategori".
- Mengurutkan/ Menyusun: Gunakan array "item_acak". Kunci berisi urutan benar.
- Isian Singkat: Kunci jawaban WAJIB HANYA 1 KATA.
8. **Integrasi Ayat Al-Quran & Hadits**: Jika materi terkait Agama Islam/BTA, sertakan teks Arab diapit tag [ARABIC] dan [/ARABIC] (contoh: [ARABIC] بِسْمِ اللَّهِ [/ARABIC]).
ATURAN JSON STRICT:
1. WAJIB KEMBALIKAN JSON VALID. Jangan terpotong.
2. Opsi Pilihan Ganda harus OBJECT dengan 4 opsi pasti {"A": "...", "B": "...", "C": "...", "D": "..."}.
3. Opsi PG Kompleks harus ARRAY string ["...", "...", "...", "..."] (Bukan object).
FORMAT JSON:
{
"kisi_kisi": [{ "tp": "...", "materi": "...", "level": "L3", "indikator": "...", "nomor": 1, "bentuk": "...", "kunci": "..." }],
"soal": [{ "nomor": 1, "bentuk": "...", "pertanyaan": "...", "stimulus_content": "...", "opsi": {"A": "..."}, "kunci": "...", "premis_kiri": ["..."], "respon_kanan": ["..."], "pernyataan": ["..."] }]
}`;
const userQuery = `Buat instrumen penilaian ${config.tingkatSekolah} Mapel ${config.mapel} ${config.kelas}.
BAB: ${config.bab}, Sub: ${config.subBab}. TP: ${config.tp}.
Bentuk & Jumlah Soal: ${bentukString}. Total: ${c.total} soal.
Total Bergambar: ${c.gambar} soal.
Guru: ${config.namaGuru}, Sekolah: ${config.sekolah}.`;
const response = await fetchWithRetry(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
maxOutputTokens: 8192
}
})
});
const data = await response.json();
if (!data.candidates || !data.candidates[0]) {
throw new Error(`Gagal memuat bagian ${i + 1}. AI tidak merespons dengan format yang benar. Coba lagi.`);
}
const rawText = data.candidates[0].content.parts[0].text;
// Bersihkan teks dari markdown
let cleanText = rawText.replace(/```json/gi, '').replace(/```/g, '').trim();
// Ekstrak JSON utuh
const firstBrace = cleanText.indexOf('{');
const lastBrace = cleanText.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
cleanText = cleanText.substring(firstBrace, lastBrace + 1);
}
let result;
try {
result = JSON.parse(cleanText);
} catch (e) {
console.error("Parse JSON Error:", e.message, "\nRaw Text Length:", cleanText.length);
throw new Error(`Terjadi pemotongan teks dari AI pada bagian ke-${i + 1}. Mohon kurangi detail materi yang terlalu panjang atau coba generate ulang.`);
}
// NORMALISASI NAMA BENTUK SOAL DARI AI
const normalisasiBentuk = (dataArray) => {
if(!dataArray) return;
dataArray.forEach(item => {
if(!item.bentuk) return;
const b = item.bentuk.toLowerCase();
if (b.includes('uraian') || b.includes('essay')) item.bentuk = 'Uraian/ Essay';
else if (b.includes('urut') || b.includes('susun')) item.bentuk = 'Mengurutkan/ Menyusun';
else if (b.includes('benar') && b.includes('salah')) item.bentuk = 'Benar Salah';
else if (b.includes('setuju')) item.bentuk = 'Setuju-Tidak Setuju';
else if (b.includes('kategori')) item.bentuk = 'Mengkategorikan';
else if (b.includes('kompleks')) item.bentuk = 'Pilihan Ganda Kompleks';
});
};
if (result.kisi_kisi) {
normalisasiBentuk(result.kisi_kisi);
allKisi.push(...result.kisi_kisi);
}
if (result.soal) {
normalisasiBentuk(result.soal);
allSoal.push(...result.soal);
}
}
setLoadingProgress('Menyusun dan merapikan urutan...');
// --- MENGURUTKAN SELURUH SOAL DAN KISI-KISI BERDASARKAN BENTUK ---
const urutanBentuk = [
'pilihan ganda', 'pg',
'pilihan ganda kompleks', 'pg kompleks',
'menjodohkan',
'benar salah',
'setuju-tidak setuju',
'mengkategorikan',
'mengurutkan/ menyusun',
'isian singkat',
'jawaban singkat',
'uraian/ essay'
];
const getOrderIndex = (bentuk) => {
if (!bentuk) return 999;
const idx = urutanBentuk.indexOf(bentuk.toLowerCase());
return idx === -1 ? 999 : idx;
};
allSoal.sort((a, b) => getOrderIndex(a.bentuk) - getOrderIndex(b.bentuk));
allKisi.sort((a, b) => getOrderIndex(a.bentuk) - getOrderIndex(b.bentuk));
// Sinkronisasi ulang nomor urut agar rapi dari 1 hingga N
allSoal.forEach((s, i) => s.nomor = i + 1);
allKisi.forEach((k, i) => k.nomor = i + 1);
setGeneratedData({
kisi_kisi: allKisi,
soal: allSoal
});
setActiveTab('preview');
} catch (error) {
console.error(error);
setErrorMsg(error.message);
} finally {
setLoading(false);
setLoadingProgress('');
}
};
// --- RENDER KE WORD (DIPERBAIKI UNTUK CETAK RAPI) ---
const handleDownloadWord = () => {
if (!generatedData) return;
let html = `
Soal Naskah
NASKAH PENILAIAN HARIAN
${config.sekolah}
| Mata Pelajaran | : | ${config.mapel} |
Nama Siswa | : | |
| Kelas/Fase | : | ${config.kelas} |
No. Absen | : | |
Jawablah pertanyaan di bawah ini dengan benar dan teliti!
`;
generatedData.soal.forEach(s => {
html += ``;
html += `| ${s.nomor}. | `;
html += ``;
if (s.stimulus_content) {
html += ` ${processExportText(s.stimulus_content)} `;
}
html += `${processExportText(s.pertanyaan)} `;
// RENDER BERDASARKAN BENTUK
if (['Pilihan Ganda', 'PG'].includes(s.bentuk) && s.opsi) {
html += ``;
Object.entries(s.opsi).forEach(([k, v]) => {
html += `| ${k}. | ${processExportText(v)} | `;
});
html += ` `;
}
else if (['PG Kompleks', 'Pilihan Ganda Kompleks'].includes(s.bentuk) && s.opsi) {
html += ``;
const opsiArray = Array.isArray(s.opsi) ? s.opsi : Object.values(s.opsi);
opsiArray.forEach(o => {
html += `
| ☐ |
${processExportText(typeof o === 'object' ? o.teks : o)} |
`;
});
html += ` `;
}
else if (['Menjodohkan'].includes(s.bentuk)) {
html += `
Lajur Kiri
`;
(s.premis_kiri || []).forEach((i, idx) => {
html += `| ${String.fromCharCode(65 + idx)}. | ${processExportText(i)} | `;
});
html += ` |
|
Lajur Kanan
`;
(s.respon_kanan || []).forEach((i, idx) => {
html += `| ${idx+1}. | ${processExportText(i)} | `;
});
html += ` | `;
}
else if (['Benar Salah'].includes(s.bentuk)) {
html += ``;
}
else if (['Setuju-Tidak Setuju'].includes(s.bentuk)) {
html += ``;
}
else if (['Mengkategorikan'].includes(s.bentuk) && s.kategori && s.item_kategori) {
html += ``;
}
else if (['Mengurutkan/ Menyusun'].includes(s.bentuk) && Array.isArray(s.item_acak)) {
html += ``;
s.item_acak.forEach(item => {
html += `| (....) | ${processExportText(item)} | `;
});
html += ` `;
}
else if (['Isian Singkat'].includes(s.bentuk)) {
html += `Jawaban: `;
}
else if (['Jawaban Singkat'].includes(s.bentuk)) {
html += `Jawaban: `;
}
else if (['Uraian/ Essay'].includes(s.bentuk)) {
html += ``;
}
html += ` |
`;
});
// KUNCI JAWABAN
html += `
KUNCI JAWABAN
${config.mapel}
| No | Jawaban |
`;
generatedData.soal.forEach(s => {
// Menggunakan Helper Kunci Jawaban dengan tag
agar baris baru di Word
html += `| ${s.nomor} | ${formatKunciJawaban(s.kunci, ' ')} |
`;
});
html += `
`;
const blob = new Blob([html], { type: 'application/msword' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `Naskah_Soal_${config.mapel.replace(/ /g, '_')}.doc`;
link.click();
};
const handleDownloadExcel = () => {
if (!generatedData) return;
let html = `
| KISI-KISI SOAL ${config.mapel.toUpperCase()} |
| Sekolah | : ${config.sekolah} |
| Kelas | : ${config.kelas} |
| NO |
TP |
MATERI |
LEVEL |
INDIKATOR |
BENTUK |
NO SOAL |
KUNCI |
`;
generatedData.kisi_kisi.forEach((item, idx) => {
html += `
| ${idx+1} |
${item.tp} |
${item.materi} |
${item.level} |
${item.indikator} |
${item.bentuk} |
${item.nomor} |
${formatKunciJawaban(item.kunci, ', ')} |
`;
});
html += `
`;
const blob = new Blob([html], { type: 'application/vnd.ms-excel' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `Kisi_Kisi_${config.mapel.replace(/ /g, '_')}.xls`;
link.click();
};
const handlePrintPDF = () => {
if (!generatedData) return;
const printWindow = window.open('', '_blank');
printWindow.document.write(`Print Naskah Soal
| Mapel | : | ${config.mapel} |
Nama | : | |
| Kelas | : | ${config.kelas} |
No. Absen | : | |
${generatedData.soal.map(s => `
| ${s.nomor}. |
${s.stimulus_content ? ` ${processExportText(s.stimulus_content)} ` : ''}
${processExportText(s.pertanyaan)}
${['Pilihan Ganda', 'PG'].includes(s.bentuk) && s.opsi ?
`` + Object.entries(s.opsi).map(([k,v]) => `| ${k}. | ${processExportText(v)} | `).join('') + ` ` : ''}
${['PG Kompleks', 'Pilihan Ganda Kompleks'].includes(s.bentuk) && s.opsi ?
`` + (Array.isArray(s.opsi) ? s.opsi : Object.values(s.opsi)).map(o => `| ☐ | ${processExportText(typeof o === 'object' ? o.teks : o)} | `).join('') + ` ` : ''}
${['Menjodohkan'].includes(s.bentuk) ?
`
Lajur Kiri
${(s.premis_kiri || []).map((i, idx) => `| ${String.fromCharCode(65+idx)}. | ${processExportText(i)} | `).join('')} |
|
Lajur Kanan
${(s.respon_kanan || []).map((i, idx) => `| ${idx+1}. | ${processExportText(i)} | `).join('')} |
` : ''}
${['Benar Salah'].includes(s.bentuk) ?
`` : ''}
${['Setuju-Tidak Setuju'].includes(s.bentuk) ?
`` : ''}
${['Mengkategorikan'].includes(s.bentuk) && s.kategori ?
`` : ''}
${['Mengurutkan/ Menyusun'].includes(s.bentuk) ?
`${(s.item_acak || []).map(item => `| (....) | ${processExportText(item)} | `).join('')} ` : ''}
${['Isian Singkat'].includes(s.bentuk) ? `Jawaban: ` : ''}
${['Jawaban Singkat'].includes(s.bentuk) ? `Jawaban: ` : ''}
${['Uraian/ Essay'].includes(s.bentuk) ? `` : ''}
|
`).join('')}
`);
printWindow.document.close();
};
return (
{/* HEADER */}
Generator Soal dan Kisi-Kisi
Dikembangkan Oleh : Asep Saefullah || SMP Negeri 3 Cikarang Timur
{errorMsg && (
)}
{activeTab === 'input' ? (
{/* KOLOM KIRI: Identitas */}
Identitas & Materi
{/* JENJANG & KELAS DINAMIS */}
{/* MATERI */}
{/* KOLOM KANAN: Konfigurasi */}
Konfigurasi Soal
{/* BENTUK SOAL & JUMLAH */}
Total: {totalSoal}/100
{[
{ type: 'Pilihan Ganda', icon:
},
{ type: 'Pilihan Ganda Kompleks', icon:
},
{ type: 'Menjodohkan', icon:
},
{ type: 'Benar Salah', icon:
},
{ type: 'Setuju-Tidak Setuju', icon:
},
{ type: 'Mengkategorikan', icon:
},
{ type: 'Mengurutkan/ Menyusun', icon:
},
{ type: 'Isian Singkat', icon:
},
{ type: 'Jawaban Singkat', icon:
},
{ type: 'Uraian/ Essay', icon:
}
].map(({ type, icon }) => {
const count = config.jumlahPerBentuk[type] || '';
const isActive = count > 0;
return (
);
})}
{/* LEVEL KOGNITIF */}
{totalLevel} / {totalSoal} Soal
{totalLevel !== totalSoal && totalSoal > 0 && (
⚠️ Total Level Kognitif ({totalLevel}) harus sama dengan Total Soal ({totalSoal}).
)}
{/* CONFIG GAMBAR */}
) : (
{/* PREVIEW & DOWNLOAD */}
{/* PREVIEW KISI-KISI */}
Preview Kisi-kisi
| NO | TP | MATERI | LEVEL | INDIKATOR | BENTUK | NO SOAL | KUNCI |
{generatedData?.kisi_kisi?.map((item, idx) => (
| {idx + 1} | {item.tp} | {item.materi} | {item.level} | {item.indikator} | {item.bentuk} | {item.nomor} | {formatKunciJawaban(item.kunci, ', ')} |
))}
{/* PREVIEW SOAL */}
NASKAH PENILAIAN HARIAN
{config.mapel}
{config.tingkatSekolah} - {config.kelas}
{generatedData?.soal?.map((s, i) => (
{s.nomor}.
))}
)}
);
};
export default App;