anoderb commited on
Commit
4411d4b
·
1 Parent(s): a14ca57

feat: add model evaluation comparison modal and evaluasi button to frontend

Browse files
Files changed (1) hide show
  1. frontend/app/page.tsx +208 -1
frontend/app/page.tsx CHANGED
@@ -96,6 +96,11 @@ export default function Dashboard() {
96
  const [forecastNewLabel, setForecastNewLabel] = useState('')
97
  const [showForecastSuccessModal, setShowForecastSuccessModal] = useState(false)
98
 
 
 
 
 
 
99
  // --- Settings Portal States ---
100
  const [runtimeSettings, setRuntimeSettings] = useState<Record<string, any>>({})
101
  const [runtimeSaving, setRuntimeSaving] = useState<string | null>(null)
@@ -422,6 +427,19 @@ export default function Dashboard() {
422
  }
423
  }
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  // ── SETTINGS PORTAL ACTIONS ────────────────────────────────────────────────
426
  const handleSaveSetting = async (key: string) => {
427
  setRuntimeSaving(key)
@@ -705,6 +723,183 @@ export default function Dashboard() {
705
  </div>
706
  )}
707
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  {/* MAIN CONTAINER CONTENT */}
709
  <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
710
 
@@ -1177,7 +1372,19 @@ export default function Dashboard() {
1177
  <span className="text-xs font-bold text-slate-900 block">{k.nama}</span>
1178
  <span className="text-[10px] text-slate-400 block mt-0.5">Volatilitas: <strong className={k.volatile ? "text-amber-600" : "text-emerald-600"}>{k.volatile ? "Tinggi" : "Rendah"}</strong></span>
1179
  </div>
1180
- <div className="flex gap-1.5">
 
 
 
 
 
 
 
 
 
 
 
 
1181
  {isRunning ? (
1182
  <button
1183
  onClick={handleStopForecast}
 
96
  const [forecastNewLabel, setForecastNewLabel] = useState('')
97
  const [showForecastSuccessModal, setShowForecastSuccessModal] = useState(false)
98
 
99
+ // --- Model Detail Modal States ---
100
+ const [showModelDetailModal, setShowModelDetailModal] = useState(false)
101
+ const [selectedCommodityDetail, setSelectedCommodityDetail] = useState<any>(null)
102
+ const [loadingDetailId, setLoadingDetailId] = useState<number | null>(null)
103
+
104
  // --- Settings Portal States ---
105
  const [runtimeSettings, setRuntimeSettings] = useState<Record<string, any>>({})
106
  const [runtimeSaving, setRuntimeSaving] = useState<string | null>(null)
 
427
  }
428
  }
429
 
430
+ const handleOpenDetailModal = async (id: number) => {
431
+ setLoadingDetailId(id)
432
+ try {
433
+ const res = await axios.get(`${API_URL}/forecast/prediksi/${id}`)
434
+ setSelectedCommodityDetail(res.data)
435
+ setShowModelDetailModal(true)
436
+ } catch (err: any) {
437
+ alert(`❌ Gagal mengambil detail model: ${err.response?.data?.detail || err.message}`)
438
+ } finally {
439
+ setLoadingDetailId(null)
440
+ }
441
+ }
442
+
443
  // ── SETTINGS PORTAL ACTIONS ────────────────────────────────────────────────
444
  const handleSaveSetting = async (key: string) => {
445
  setRuntimeSaving(key)
 
723
  </div>
724
  )}
725
 
726
+ {/* MODEL EVALUATION COMPARISON MODAL */}
727
+ {showModelDetailModal && selectedCommodityDetail && (
728
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm overflow-y-auto animate-fade-in">
729
+ <div className="bg-white rounded-2xl max-w-3xl w-full p-6 shadow-2xl relative border border-slate-200 my-8 animate-scale-in">
730
+ <button
731
+ onClick={() => setShowModelDetailModal(false)}
732
+ className="absolute top-4 right-4 p-2 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-all duration-200"
733
+ >
734
+ <X className="w-5 h-5" />
735
+ </button>
736
+
737
+ <div className="flex items-center gap-3 mb-1">
738
+ <div className="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600 border border-indigo-100">
739
+ <Activity className="w-5 h-5" />
740
+ </div>
741
+ <div>
742
+ <h3 className="text-lg font-bold text-slate-900 leading-snug">
743
+ Evaluasi Model & Proyeksi Harga
744
+ </h3>
745
+ <p className="text-xs text-slate-500">
746
+ {selectedCommodityDetail.komoditas?.nama} — Detail performa model latih terbaru.
747
+ </p>
748
+ </div>
749
+ </div>
750
+
751
+ <hr className="my-5 border-slate-100" />
752
+
753
+ {/* Model Comparison Table */}
754
+ <div className="mb-6">
755
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-3">
756
+ Perbandingan Performa Model Latih
757
+ </span>
758
+
759
+ {(!selectedCommodityDetail.all_models || selectedCommodityDetail.all_models.length === 0) ? (
760
+ <div className="bg-slate-50 rounded-xl p-6 text-center text-slate-400 text-xs italic border border-slate-200/50">
761
+ Belum ada model yang dilatih. Silakan jalankan forecast terlebih dahulu.
762
+ </div>
763
+ ) : (
764
+ <div className="overflow-x-auto border border-slate-200/80 rounded-xl">
765
+ <table className="w-full text-left border-collapse">
766
+ <thead>
767
+ <tr className="bg-slate-50 border-b border-slate-200 text-[10px] text-slate-500 font-bold uppercase tracking-wider">
768
+ <th className="py-3 px-4">Nama Model</th>
769
+ <th className="py-3 px-3 text-right">MAPE (%)</th>
770
+ <th className="py-3 px-3 text-right">RMSE</th>
771
+ <th className="py-3 px-3 text-right">MAE</th>
772
+ <th className="py-3 px-3 text-right">R² Score</th>
773
+ <th className="py-3 px-4 text-center">Status</th>
774
+ </tr>
775
+ </thead>
776
+ <tbody className="divide-y divide-slate-100 text-xs text-slate-700">
777
+ {selectedCommodityDetail.all_models.map((m: any, i: number) => (
778
+ <tr
779
+ key={i}
780
+ className={`transition ${m.is_active ? 'bg-emerald-50/40 hover:bg-emerald-50/60 font-semibold' : 'hover:bg-slate-50/30'}`}
781
+ >
782
+ <td className="py-3 px-4 font-bold text-slate-900 flex items-center gap-1.5">
783
+ {m.nama_model}
784
+ {m.is_active === 1 && (
785
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
786
+ )}
787
+ </td>
788
+ <td className="py-3 px-3 text-right font-mono font-bold text-indigo-600">
789
+ {m.mape !== null ? `${Number(m.mape).toFixed(2)}%` : '-'}
790
+ </td>
791
+ <td className="py-3 px-3 text-right font-mono text-slate-600">
792
+ {m.rmse !== null ? Number(m.rmse).toLocaleString('id-ID', { maximumFractionDigits: 2 }) : '-'}
793
+ </td>
794
+ <td className="py-3 px-3 text-right font-mono text-slate-600">
795
+ {m.mae !== null ? Number(m.mae).toLocaleString('id-ID', { maximumFractionDigits: 2 }) : '-'}
796
+ </td>
797
+ <td className="py-3 px-3 text-right font-mono text-slate-500">
798
+ {m.r2_score !== null ? Number(m.r2_score).toFixed(4) : '-'}
799
+ </td>
800
+ <td className="py-3 px-4 text-center">
801
+ {m.is_active === 1 ? (
802
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold bg-emerald-100 text-emerald-800 border border-emerald-200">
803
+ Terbaik & Terpilih
804
+ </span>
805
+ ) : (
806
+ <span className="text-[10px] text-slate-400">Tersimpan</span>
807
+ )}
808
+ </td>
809
+ </tr>
810
+ ))}
811
+ </tbody>
812
+ </table>
813
+ </div>
814
+ )}
815
+ </div>
816
+
817
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-5 mb-6">
818
+ {/* Proyeksi Harga Card */}
819
+ <div className="bg-slate-50 rounded-2xl p-5 border border-slate-200/60">
820
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-3">
821
+ Hasil Proyeksi Harga 7 Hari Ke Depan
822
+ </span>
823
+
824
+ {(!selectedCommodityDetail.prediksi_7hari || selectedCommodityDetail.prediksi_7hari.length === 0) ? (
825
+ <div className="text-slate-400 text-xs italic py-4">Belum ada proyeksi harga.</div>
826
+ ) : (
827
+ <div className="space-y-2.5 max-h-[220px] overflow-y-auto pr-1">
828
+ {selectedCommodityDetail.prediksi_7hari.map((p: any, idx: number) => (
829
+ <div key={idx} className="flex justify-between items-center bg-white p-2.5 rounded-xl border border-slate-100 shadow-sm">
830
+ <span className="text-xs font-semibold text-slate-600">
831
+ {new Date(p.tanggal_target).toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short' })}
832
+ </span>
833
+ <span className="text-xs font-bold text-slate-900">
834
+ Rp {Number(p.harga_prediksi).toLocaleString('id-ID', { maximumFractionDigits: 0 })}
835
+ </span>
836
+ </div>
837
+ ))}
838
+ </div>
839
+ )}
840
+ </div>
841
+
842
+ {/* Ringkasan Analisis Card */}
843
+ <div className="flex flex-col gap-4">
844
+ <div className="bg-slate-50 rounded-2xl p-5 border border-slate-200/60 flex-1">
845
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">
846
+ Kesimpulan Analisis
847
+ </span>
848
+
849
+ {selectedCommodityDetail.ringkasan ? (
850
+ <div>
851
+ <div className="flex items-center gap-1.5 mb-2">
852
+ <span className="text-xs font-black text-slate-900">
853
+ {selectedCommodityDetail.ringkasan.status_analisis}
854
+ </span>
855
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wide
856
+ ${selectedCommodityDetail.ringkasan.tren?.includes('naik') ? 'bg-rose-50 text-rose-700 border border-rose-100' :
857
+ selectedCommodityDetail.ringkasan.tren?.includes('turun') ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' :
858
+ 'bg-slate-100 text-slate-600 border border-slate-200'}`}
859
+ >
860
+ {selectedCommodityDetail.ringkasan.tren?.replace('_', ' ')}
861
+ </span>
862
+ </div>
863
+ <p className="text-xs text-slate-500 leading-relaxed">
864
+ {selectedCommodityDetail.ringkasan.deskripsi_status}
865
+ </p>
866
+ </div>
867
+ ) : (
868
+ <div className="text-slate-400 text-xs italic py-4">Belum ada data kesimpulan.</div>
869
+ )}
870
+ </div>
871
+
872
+ {/* Insights List */}
873
+ {selectedCommodityDetail.insights && selectedCommodityDetail.insights.length > 0 && (
874
+ <div className="bg-slate-50 rounded-2xl p-5 border border-slate-200/60">
875
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-3">
876
+ Temuan & Insight Pasar
877
+ </span>
878
+ <ul className="space-y-2">
879
+ {selectedCommodityDetail.insights.map((ins: any, idx: number) => (
880
+ <li key={idx} className="flex gap-2 text-xs text-slate-600 leading-normal">
881
+ <span className="w-1.5 h-1.5 rounded-full bg-indigo-500 mt-1.5 flex-shrink-0" />
882
+ <span>{ins.konten}</span>
883
+ </li>
884
+ ))}
885
+ </ul>
886
+ </div>
887
+ )}
888
+ </div>
889
+ </div>
890
+
891
+ <div className="text-right pt-4 border-t border-slate-100">
892
+ <button
893
+ onClick={() => setShowModelDetailModal(false)}
894
+ className="bg-slate-900 hover:bg-slate-800 text-white font-bold py-2.5 px-6 rounded-xl transition-all duration-200 shadow-md text-xs"
895
+ >
896
+ Tutup Evaluasi
897
+ </button>
898
+ </div>
899
+ </div>
900
+ </div>
901
+ )}
902
+
903
  {/* MAIN CONTAINER CONTENT */}
904
  <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
905
 
 
1372
  <span className="text-xs font-bold text-slate-900 block">{k.nama}</span>
1373
  <span className="text-[10px] text-slate-400 block mt-0.5">Volatilitas: <strong className={k.volatile ? "text-amber-600" : "text-emerald-600"}>{k.volatile ? "Tinggi" : "Rendah"}</strong></span>
1374
  </div>
1375
+ <div className="flex gap-1.5 items-center">
1376
+ <button
1377
+ onClick={() => handleOpenDetailModal(k.id)}
1378
+ disabled={loadingDetailId === k.id || isRunning}
1379
+ className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] font-bold bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 transition-all duration-150 active:scale-[0.98] disabled:opacity-50"
1380
+ >
1381
+ {loadingDetailId === k.id ? (
1382
+ <RefreshCw className="w-3 h-3 animate-spin" />
1383
+ ) : (
1384
+ <Info className="w-3 h-3 text-slate-400" />
1385
+ )}
1386
+ Evaluasi
1387
+ </button>
1388
  {isRunning ? (
1389
  <button
1390
  onClick={handleStopForecast}