GDank-StockSense / static /index.html
ArizalMuluk's picture
Upload 13 files
b0e8ab4 verified
Raw
History Blame Contribute Delete
32.8 kB
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>GDank — Prediksi Stok Gudang</title>
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js"></script>
<style>
:root {
--primary : #2563eb;
--primary-dk: #1d4ed8;
--success : #16a34a;
--warning : #d97706;
--danger : #dc2626;
--bg : #f8fafc;
--card : #ffffff;
--border : #e2e8f0;
--text : #1e293b;
--muted : #64748b;
--radius : 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); }
/* ── HEADER ── */
header { background: var(--primary); color: #fff; padding: 20px 32px;
display: flex; align-items: center; gap: 14px; box-shadow: 0 2px 8px #0002; }
header h1 { font-size: 1.5rem; font-weight: 700; }
header span { font-size: 0.9rem; opacity: .8; }
.badge { background: #ffffff30; border-radius: 20px; padding: 2px 12px; font-size: .78rem; }
/* ── LAYOUT ── */
main { max-width: 960px; margin: 32px auto; padding: 0 20px; display: flex; flex-direction: column; gap: 24px; }
/* ── CARD ── */
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px; box-shadow: 0 1px 4px #0001; }
.card h2 { font-size: 1.05rem; font-weight: 600; margin-bottom: 18px;
display: flex; align-items: center; gap: 8px; }
.step-badge { background: var(--primary); color: #fff; border-radius: 50%;
width: 26px; height: 26px; display: inline-flex;
align-items: center; justify-content: center; font-size: .8rem; font-weight: 700; }
/* ── UPLOAD ZONE ── */
.upload-zone { border: 2px dashed var(--border); border-radius: 10px; padding: 40px 20px;
text-align: center; cursor: pointer; transition: .2s;
background: #f1f5f9; }
.upload-zone:hover, .upload-zone.drag { border-color: var(--primary); background: #eff6ff; }
.upload-zone input { display: none; }
.upload-zone p { color: var(--muted); margin-top: 8px; font-size: .9rem; }
.upload-icon { font-size: 2.5rem; }
/* ── BUTTONS ── */
.btn { padding: 10px 22px; border-radius: 8px; font-size: .9rem; font-weight: 600;
cursor: pointer; border: none; transition: .15s; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dk); }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { opacity: .9; }
.btn-outline { background: transparent; color: var(--primary); border: 2px solid var(--primary); }
.btn-outline:hover { background: #eff6ff; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
/* ── FORM ── */
.form-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
label { font-size: .85rem; color: var(--muted); display: block; margin-bottom: 5px; }
input, select { width: 100%; padding: 9px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: .9rem; background: #f8fafc; }
input:focus, select:focus { outline: none; border-color: var(--primary); }
/* ── DETECTION TABLE ── */
.detect-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.detect-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px;
border-radius: 8px; font-size: .85rem; background: #f8fafc; border: 1px solid var(--border); }
.detect-item.ok { background: #f0fdf4; border-color: #bbf7d0; }
.detect-item.warn { background: #fffbeb; border-color: #fde68a; }
.detect-item.err { background: #fef2f2; border-color: #fecaca; }
/* ── METRICS ── */
.metrics-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.metric-card { background: #f8fafc; border: 1px solid var(--border);
border-radius: 10px; padding: 16px; text-align: center; }
.metric-val { font-size: 1.8rem; font-weight: 700; color: var(--primary); }
.metric-lbl { font-size: .78rem; color: var(--muted); margin-top: 4px; }
/* ── TABLE ── */
.table-wrap { overflow-x: auto; margin-top: 12px; }
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
th { background: #f1f5f9; padding: 9px 12px; text-align: left;
font-weight: 600; border-bottom: 2px solid var(--border); }
td { padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
tr:hover td { background: #fafafa; }
.status-ok { color: var(--success); font-weight: 600; }
.status-warn { color: var(--warning); font-weight: 600; }
.status-err { color: var(--danger); font-weight: 600; }
/* ── PRODUCT SELECTOR ── */
.prod-row { display: flex; gap: 10px; align-items: flex-end; }
.prod-row > div { flex: 1; }
/* ── ALERTS ── */
.alert { padding: 12px 16px; border-radius: 8px; font-size: .88rem; margin-top: 12px; }
.alert-info { background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; }
.alert-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #15803d; }
.alert-danger { background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; }
.alert-warning { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; }
/* ── PROGRESS ── */
.progress-wrap { margin-top: 12px; }
.progress-bar { height: 6px; background: #e2e8f0; border-radius: 99px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--primary); border-radius: 99px;
width: 0%; transition: width .4s; }
/* ── SPINNER ── */
.spinner { display: inline-block; width: 18px; height: 18px;
border: 3px solid #fff4; border-top-color: #fff;
border-radius: 50%; animation: spin .6s linear infinite; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── HIDDEN ── */
.hidden { display: none !important; }
/* ── FOOTER ── */
footer { text-align: center; color: var(--muted); font-size: .8rem; padding: 32px 0 20px; }
</style>
</head>
<body>
<header>
<div style="font-size:2rem">📦</div>
<div>
<h1>GDank <span class="badge">v2.0</span></h1>
<span>Prediksi Stok Gudang Bulanan · Transfer Learning · LightGBM</span>
</div>
</header>
<main>
<!-- ── STEP 1: UPLOAD ── -->
<div class="card" id="card-upload">
<h2><span class="step-badge">1</span> Upload Dataset</h2>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="upload-icon">📂</div>
<strong>Klik atau drag & drop file dataset</strong>
<p>Format didukung: CSV · Excel (.xlsx) · Parquet · JSON</p>
<input type="file" id="file-input" accept=".csv,.xlsx,.xls,.parquet,.json"/>
</div>
<div id="upload-status" class="hidden"></div>
<!-- Detection result -->
<div id="detection-result" class="hidden" style="margin-top:18px">
<strong style="font-size:.9rem">🔍 Hasil Deteksi Kolom:</strong>
<div class="detect-grid" id="detect-grid" style="margin-top:8px"></div>
<div id="detect-warnings" style="margin-top:8px"></div>
<div id="detect-error" style="margin-top:8px"></div>
<div style="margin-top:14px">
<strong style="font-size:.9rem">👁️ Preview Data (5 baris pertama):</strong>
<div class="table-wrap" id="preview-table" style="margin-top:6px"></div>
</div>
</div>
</div>
<!-- ── STEP 2: TRAINING ── -->
<div class="card hidden" id="card-train">
<h2><span class="step-badge">2</span> Konfigurasi & Training</h2>
<div class="form-row">
<div>
<label>Kode Negara (Hari Libur)</label>
<select id="country-code">
<option value="ID" selected>🇮🇩 Indonesia (ID)</option>
<option value="MY">🇲🇾 Malaysia (MY)</option>
<option value="SG">🇸🇬 Singapura (SG)</option>
<option value="US">🇺🇸 Amerika (US)</option>
<option value="GB">🇬🇧 UK (GB)</option>
</select>
</div>
<div>
<label>Rasio Data Test</label>
<select id="test-ratio">
<option value="0.15">15%</option>
<option value="0.20" selected>20% (rekomendasi)</option>
<option value="0.25">25%</option>
<option value="0.30">30%</option>
</select>
</div>
<div>
<label>Optuna Trials</label>
<select id="n-trials">
<option value="10">10 (cepat)</option>
<option value="30" selected>30 (rekomendasi)</option>
<option value="50">50 (akurat)</option>
</select>
</div>
</div>
<div class="alert alert-info" style="margin-top:14px">
💡 Model akan di-<strong>fine-tune</strong> dari base model 300k++ menggunakan <strong>Transfer Learning</strong>.
Proses training mungkin membutuhkan beberapa menit.
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-train" onclick="startTraining()">
🚀 Mulai Training
</button>
</div>
<div id="train-progress" class="progress-wrap hidden">
<p style="font-size:.85rem; color:var(--muted); margin-bottom:6px" id="train-status-text">
Mempersiapkan data...
</p>
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
</div>
<div id="train-result" class="hidden" style="margin-top:20px">
<div class="metrics-row">
<div class="metric-card">
<div class="metric-val" id="m-mae"></div>
<div class="metric-lbl">MAE (pcs)</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-rmse"></div>
<div class="metric-lbl">RMSE (pcs)</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-mape"></div>
<div class="metric-lbl">MAPE (%)</div>
</div>
</div>
</div>
</div>
<!-- ── STEP 3: PREDIKSI ── -->
<div class="card hidden" id="card-predict">
<h2><span class="step-badge">3</span> Lihat Prediksi</h2>
<div class="prod-row">
<div>
<label>Pilih Produk</label>
<select id="product-select">
<option value="">— Top 5 Produk Terlaris —</option>
</select>
</div>
<div>
<button class="btn btn-success" onclick="loadPrediction()">📊 Tampilkan Prediksi</button>
</div>
</div>
<div id="prediction-output" style="margin-top:20px"></div>
</div>
<!-- ── STEP 4: FORECAST MASA DEPAN ── -->
<div class="card hidden" id="card-forecast">
<h2><span class="step-badge">4</span> Prediksi Stok Masa Depan</h2>
<!-- Toggle pola prediksi -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div id="pola-skip" onclick="setPola(true)"
style="border:2px solid var(--primary); border-radius:10px; padding:14px 16px;
cursor:pointer; background:#eff6ff; transition:.15s;">
<div style="font-weight:700; color:var(--primary); margin-bottom:4px">
⏭ Lewati N+1
<span style="font-size:.72rem; background:var(--primary); color:#fff;
border-radius:4px; padding:1px 7px; margin-left:6px">DEFAULT</span>
</div>
<div style="font-size:.82rem; color:var(--muted)">
Laporan bulan ini <strong>belum masuk</strong>.<br>
Prediksi untuk bulan <strong>N+2</strong>.
</div>
</div>
<div id="pola-direct" onclick="setPola(false)"
style="border:2px solid var(--border); border-radius:10px; padding:14px 16px;
cursor:pointer; background:#f8fafc; transition:.15s;">
<div style="font-weight:700; color:var(--muted); margin-bottom:4px">
✅ Data Sudah Tersedia
</div>
<div style="font-size:.82rem; color:var(--muted)">
Laporan bulan ini <strong>sudah ada</strong>.<br>
Prediksi langsung bulan <strong>N+1</strong>.
</div>
</div>
</div>
<div class="prod-row" style="margin-top:14px">
<div>
<label>Pilih Produk (kosongkan untuk top 10 terlaris)</label>
<select id="forecast-product-select">
<option value="">— Top 10 Produk Terlaris —</option>
</select>
</div>
<div>
<button class="btn btn-primary" onclick="loadForecast()">
🔮 Prediksi Masa Depan
</button>
</div>
</div>
<div id="forecast-output" style="margin-top:20px"></div>
</div>
</main>
<footer>GDank v2.0 · LightGBM + Transfer Learning · Built with FastAPI</footer>
<script>
// ─────────────────────────────────────
// STATE
// ─────────────────────────────────────
let sessionId = null;
let topProducts = [];
// ─────────────────────────────────────
// DRAG & DROP
// ─────────────────────────────────────
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag');
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
});
document.getElementById('file-input').addEventListener('change', e => {
if (e.target.files[0]) handleFile(e.target.files[0]);
});
// ─────────────────────────────────────
// UPLOAD
// ─────────────────────────────────────
async function handleFile(file) {
const statusEl = document.getElementById('upload-status');
const detEl = document.getElementById('detection-result');
statusEl.className = 'alert alert-info';
statusEl.innerHTML = `<span class="spinner"></span> Mengupload <strong>${file.name}</strong>...`;
statusEl.classList.remove('hidden');
detEl.classList.add('hidden');
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/upload', { method: 'POST', body: form });
const data = await res.json();
if (!res.ok || data.status === 'error') {
statusEl.className = 'alert alert-danger';
statusEl.innerHTML = `❌ <strong>${data.message || 'Upload gagal'}</strong><br>
${data.missing_required ? 'Kolom wajib tidak ditemukan: <b>' + data.missing_required.join(', ') + '</b>' : ''}
<br><small>${data.suggestion || ''}</small>
<br><small>Kolom terdeteksi: ${(data.columns_found || []).join(', ')}</small>`;
return;
}
sessionId = data.session_id;
statusEl.className = 'alert alert-success';
statusEl.innerHTML = `✅ <strong>${file.name}</strong> berhasil diupload ·
${data.total_rows.toLocaleString()} baris · Format: ${data.file_info.format}`;
// Detection grid
const grid = document.getElementById('detect-grid');
const warns = document.getElementById('detect-warnings');
const errs = document.getElementById('detect-error');
grid.innerHTML = '';
data.detection.detected.forEach(d => {
grid.innerHTML += `<div class="detect-item ok">
✅ <span><strong>${d.label}</strong> → "${d.mapped_to}"</span></div>`;
});
data.detection.not_found.forEach(d => {
const cls = d.required ? 'err' : 'warn';
const ico = d.required ? '❌' : '⚠️';
grid.innerHTML += `<div class="detect-item ${cls}">
${ico} <span><strong>${d.label}</strong> → ${d.required ? 'tidak ditemukan' : 'tidak ditemukan (opsional)'}</span></div>`;
});
// Validation warnings
warns.innerHTML = '';
if (data.validation.warnings.length > 0) {
data.validation.warnings.forEach(w => {
warns.innerHTML += `<div class="alert alert-warning" style="margin-top:6px">⚠️ ${w}</div>`;
});
}
// Dataset info
const info = data.validation.info;
errs.innerHTML = `<div class="alert alert-info" style="margin-top:8px">
📊 <strong>${info.total_rows.toLocaleString()}</strong> baris ·
<strong>${info.total_products.toLocaleString()}</strong> produk ·
Rentang: <strong>${info.date_range}</strong> ·
<strong>${info.total_months}</strong> bulan</div>`;
// Preview table
buildTable(data.preview, document.getElementById('preview-table'));
detEl.classList.remove('hidden');
// Show step 2
document.getElementById('card-train').classList.remove('hidden');
document.getElementById('card-train').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
statusEl.className = 'alert alert-danger';
statusEl.innerHTML = `❌ Terjadi kesalahan: ${err.message}`;
}
}
// ─────────────────────────────────────
// TRAINING
// ─────────────────────────────────────
async function startTraining() {
if (!sessionId) return;
const btn = document.getElementById('btn-train');
const prog = document.getElementById('train-progress');
const fill = document.getElementById('progress-fill');
const statusTxt = document.getElementById('train-status-text');
const result = document.getElementById('train-result');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Training...';
prog.classList.remove('hidden');
result.classList.add('hidden');
// Simulasi progress bar
const steps = [
[10, 'Memuat base model 300k++...'],
[25, 'Preprocessing dataset...'],
[40, 'Feature engineering...'],
[55, 'Optuna hyperparameter tuning...'],
[80, 'Training final model...'],
[95, 'Evaluasi model...'],
];
let si = 0;
const iv = setInterval(() => {
if (si < steps.length) {
fill.style.width = steps[si][0] + '%';
statusTxt.textContent = steps[si][1];
si++;
}
}, 1800);
try {
const res = await fetch('/train', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
session_id : sessionId,
country_code: document.getElementById('country-code').value,
test_ratio : parseFloat(document.getElementById('test-ratio').value),
n_trials : parseInt(document.getElementById('n-trials').value),
})
});
const data = await res.json();
clearInterval(iv);
if (!res.ok) {
fill.style.width = '100%';
fill.style.background = '#dc2626';
statusTxt.textContent = '❌ Training gagal: ' + (data.detail || 'Unknown error');
btn.disabled = false;
btn.innerHTML = '🚀 Coba Lagi';
return;
}
fill.style.width = '100%';
fill.style.background = '#16a34a';
statusTxt.textContent = '✅ Training selesai!';
// Tampilkan metrics
document.getElementById('m-mae').textContent = data.metrics.mae;
document.getElementById('m-rmse').textContent = data.metrics.rmse;
document.getElementById('m-mape').textContent = data.metrics.mape + '%';
result.classList.remove('hidden');
// Isi dropdown produk
topProducts = data.top_products || [];
const sel = document.getElementById('product-select');
sel.innerHTML = '<option value="">— Top 5 Produk Terlaris —</option>';
topProducts.forEach(p => {
sel.innerHTML += `<option value="${p}">${p}</option>`;
});
// Load full product list
loadProductList();
// Show step 4 forecast
document.getElementById('card-forecast').classList.remove('hidden');
// Sync product list to forecast dropdown too
const fsel = document.getElementById('forecast-product-select');
fsel.innerHTML = '<option value="">— Top 10 Produk Terlaris —</option>';
btn.innerHTML = '✅ Training Selesai';
document.getElementById('card-predict').classList.remove('hidden');
document.getElementById('card-predict').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
clearInterval(iv);
statusTxt.textContent = '❌ Error: ' + err.message;
btn.disabled = false;
btn.innerHTML = '🚀 Coba Lagi';
}
}
async function loadProductList() {
try {
const res = await fetch(`/products/${sessionId}`);
const data = await res.json();
if (data.status !== 'success') return;
const sel = document.getElementById('product-select');
const fsel = document.getElementById('forecast-product-select');
sel.innerHTML = '<option value="">— Top 5 Produk Terlaris —</option>';
fsel.innerHTML = '<option value="">— Top 10 Produk Terlaris —</option>';
data.products.forEach(p => {
const opt = `<option value="${p.Product_ID}">${p.Product_ID}${p.Product_Name} (${p.total_quantity} pcs)</option>`;
sel.innerHTML += opt;
fsel.innerHTML += opt;
});
} catch (e) {}
}
// ─────────────────────────────────────
// FORECAST MASA DEPAN
// ─────────────────────────────────────
let skipN1 = true; // default: lewati N+1
function setPola(doSkip) {
skipN1 = doSkip;
const elSkip = document.getElementById('pola-skip');
const elDirect = document.getElementById('pola-direct');
if (doSkip) {
elSkip.style.border = '2px solid var(--primary)';
elSkip.style.background = '#eff6ff';
elSkip.querySelector('div').style.color = 'var(--primary)';
elDirect.style.border = '2px solid var(--border)';
elDirect.style.background = '#f8fafc';
elDirect.querySelector('div').style.color = 'var(--muted)';
} else {
elDirect.style.border = '2px solid var(--success)';
elDirect.style.background = '#f0fdf4';
elDirect.querySelector('div').style.color = 'var(--success)';
elSkip.style.border = '2px solid var(--border)';
elSkip.style.background = '#f8fafc';
elSkip.querySelector('div').style.color = 'var(--muted)';
}
}
async function loadForecast() {
if (!sessionId) return;
const pid = document.getElementById('forecast-product-select').value;
const output = document.getElementById('forecast-output');
output.innerHTML = '<div class="alert alert-info"><span class="spinner"></span> Menghitung prediksi masa depan...</div>';
try {
const skipParam = `skip_n1=${skipN1}`;
const url = pid
? `/forecast/${sessionId}?product_id=${encodeURIComponent(pid)}&${skipParam}`
: `/forecast/${sessionId}?${skipParam}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) {
output.innerHTML = `<div class="alert alert-danger">❌ ${data.detail || 'Gagal memuat prediksi'}</div>`;
return;
}
output.innerHTML = '';
// Info pola
const polaColor = data.skip_n1 ? 'alert-info' : 'alert-success';
const polaIco = data.skip_n1 ? '⏭' : '✅';
output.innerHTML += `
<div class="alert ${polaColor}" style="margin-bottom:16px">
${polaIco} Pola aktif: <strong>${data.pattern}</strong>
</div>`;
data.results.forEach(r => {
const f = r.forecast;
const chartId = 'fchart-' + r.product_id.replace(/[^a-z0-9]/gi, '_');
output.innerHTML += `
<div style="margin-bottom:32px; border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<!-- Header produk -->
<div style="background:#f8fafc; padding:16px 20px; border-bottom:1px solid var(--border);">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
<div>
<strong style="font-size:1rem">${r.product_id}</strong>
<span style="color:var(--muted); margin-left:8px">${r.product_name}</span>
</div>
<span style="font-size:.8rem; color:var(--muted)">${r.category} · ${r.types}</span>
</div>
</div>
<!-- Chart -->
<div style="padding:16px">
<div id="${chartId}" style="width:100%; height:400px;"></div>
</div>
<!-- Prediksi box -->
<div style="padding:0 20px 20px">
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; margin-bottom:14px">
<div class="metric-card" style="background:#f0fdf4; border-color:#bbf7d0">
<div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">Data Terakhir</div>
<div style="font-size:1.1rem; font-weight:700; color:#15803d">${f.last_data}</div>
</div>
<div class="metric-card" style="${f.skip_month ? 'background:#fffbeb; border-color:#fde68a' : 'background:#f0fdf4; border-color:#bbf7d0'}">
<div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">
${f.skip_month ? '⏭ Dilewati (N+1)' : '✅ Mode N+1'}
</div>
<div style="font-size:1.1rem; font-weight:700; color:${f.skip_month ? '#92400e' : '#15803d'}">
${f.skip_month || '—'}
</div>
</div>
<div class="metric-card" style="background:#eff6ff; border-color:#bfdbfe">
<div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">🎯 Prediksi Stok</div>
<div style="font-size:1.8rem; font-weight:800; color:var(--primary)">${f.prediksi_pcs} <span style="font-size:1rem">pcs</span></div>
<div style="font-size:.78rem; color:var(--muted)">${f.target_month}</div>
</div>
</div>
<!-- Keterangan -->
<div class="alert alert-info" style="font-size:.84rem">
💡 ${f.keterangan}
</div>
<!-- Fitur input -->
<details style="margin-top:10px">
<summary style="cursor:pointer; font-size:.84rem; color:var(--muted); user-select:none">
🔍 Lihat fitur input yang digunakan model
</summary>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:10px">
${Object.entries(r.fitur_input).map(([k,v]) =>
`<div style="background:#f8fafc; border:1px solid var(--border); border-radius:8px; padding:8px 12px; font-size:.82rem">
<span style="color:var(--muted)">${k}</span><br>
<strong>${v}</strong>
</div>`
).join('')}
</div>
</details>
</div>
</div>`;
// Render chart setelah HTML di-inject
setTimeout(() => {
if (r.chart_json && document.getElementById(chartId)) {
Plotly.newPlot(chartId, r.chart_json.data, r.chart_json.layout,
{ responsive: true, displayModeBar: false });
}
}, 150);
});
} catch (err) {
output.innerHTML = `<div class="alert alert-danger">❌ ${err.message}</div>`;
}
}
// ─────────────────────────────────────
// PREDIKSI
// ─────────────────────────────────────
async function loadPrediction() {
if (!sessionId) return;
const pid = document.getElementById('product-select').value;
const output = document.getElementById('prediction-output');
output.innerHTML = '<div class="alert alert-info"><span class="spinner"></span> Memuat prediksi...</div>';
try {
const url = pid ? `/predict/${sessionId}?product_id=${pid}` : `/predict/${sessionId}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) {
output.innerHTML = `<div class="alert alert-danger">❌ ${data.detail || 'Gagal memuat prediksi'}</div>`;
return;
}
output.innerHTML = '';
data.results.forEach(r => {
if (r.error) {
output.innerHTML += `<div class="alert alert-warning">⚠️ ${r.error}</div>`;
return;
}
// Chart
const chartId = 'chart-' + r.product_id;
const reportId = 'report-' + r.product_id;
output.innerHTML += `
<div style="margin-bottom:28px">
<div id="${chartId}" style="width:100%; height:420px; border:1px solid var(--border); border-radius:10px;"></div>
<div id="${reportId}" style="margin-top:16px"></div>
</div>`;
// Render chart
setTimeout(() => {
Plotly.newPlot(chartId, r.chart_json.data, r.chart_json.layout,
{ responsive: true, displayModeBar: false });
// Render report table
const rep = r.report;
let tbl = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px">
<strong>📋 ${rep.product_id}${rep.product_name}</strong>
<span style="font-size:.82rem; color:var(--muted)">${rep.kategori} · ${rep.tipe}</span>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>No</th><th>Bulan</th><th>Aktual</th><th>Prediksi</th>
<th>Selisih</th><th>%</th><th>Status</th>
</tr></thead><tbody>`;
rep.rows.forEach(row => {
const cls = row.status === 'Akurat' ? 'status-ok' : (row.status === 'Cukup' ? 'status-warn' : 'status-err');
const ico = row.status === 'Akurat' ? '✅' : (row.status === 'Cukup' ? '⚠️' : '❌');
tbl += `<tr>
<td>${row.no}</td><td>${row.bulan}</td>
<td>${row.aktual}</td><td><strong>${row.prediksi}</strong></td>
<td>${row.selisih >= 0 ? '+' : ''}${row.selisih}</td>
<td>${row.selisih_pct}%</td>
<td class="${cls}">${ico} ${row.status}</td>
</tr>`;
});
tbl += `</tbody></table></div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:14px">
<div class="metric-card"><div class="metric-val" style="font-size:1.3rem">${rep.total_aktual}</div><div class="metric-lbl">Total Aktual</div></div>
<div class="metric-card"><div class="metric-val" style="font-size:1.3rem">${rep.total_prediksi}</div><div class="metric-lbl">Total Prediksi</div></div>
<div class="metric-card"><div class="metric-val" style="font-size:1.3rem">${rep.mae}</div><div class="metric-lbl">MAE (pcs)</div></div>
<div class="metric-card"><div class="metric-val" style="font-size:1.3rem">${rep.mape}%</div><div class="metric-lbl">MAPE</div></div>
</div>
<div class="alert alert-info" style="margin-top:10px; font-size:.85rem">
📈 Kecenderungan: <strong>${rep.kecenderungan}</strong> ·
Rata-rata selisih: <strong>${rep.rata_selisih >= 0 ? '+' : ''}${rep.rata_selisih}</strong> pcs/bulan
</div>`;
document.getElementById(reportId).innerHTML = tbl;
}, 100);
});
} catch (err) {
output.innerHTML = `<div class="alert alert-danger">❌ ${err.message}</div>`;
}
}
// ─────────────────────────────────────
// HELPER: Build HTML table from array of objects
// ─────────────────────────────────────
function buildTable(rows, container) {
if (!rows || rows.length === 0) { container.innerHTML = '<p>Tidak ada data.</p>'; return; }
const cols = Object.keys(rows[0]);
let html = '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr></thead><tbody>';
rows.forEach(r => {
html += '<tr>' + cols.map(c => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
}
</script>
</body>
</html>