Spaces:
Sleeping
Sleeping
| <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 ; } | |
| /* ── 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> | |