anoderb commited on
Commit
cd4b768
·
1 Parent(s): e84aeea

Optimize real-time sync, add success modal, per-item stop, and simplify pages

Browse files
frontend/app/dashboard/forecast/page.tsx CHANGED
@@ -1,8 +1,8 @@
1
- // app/dashboard/forecast/page.tsx — Forecast Management: Scheduler + Logs + Run
2
  'use client'
3
  import { useEffect, useState, useRef } from 'react'
4
  import { api } from '@/lib/api'
5
- import { Play, Square, RefreshCw, Calendar, Terminal, Plus, Trash2, Info } from 'lucide-react'
6
 
7
  interface LogItem { timestamp: string; level: string; source: string; message: string }
8
 
@@ -14,6 +14,8 @@ export default function ForecastPage() {
14
  const [runningIds, setRunningIds] = useState<Record<number, boolean>>({})
15
  const [runAllLoading, setRunAllLoading] = useState(false)
16
  const [logLevelFilter, setLogLevelFilter] = useState('ALL')
 
 
17
 
18
  // Schedule form
19
  const [selectedHour, setSelectedHour] = useState('01')
@@ -22,6 +24,7 @@ export default function ForecastPage() {
22
 
23
  const logsContainerRef = useRef<HTMLDivElement>(null)
24
  const prevLogsLengthRef = useRef(0)
 
25
 
26
  const fetchData = async () => {
27
  try {
@@ -30,18 +33,35 @@ export default function ForecastPage() {
30
  api.getLogs(200, logLevelFilter !== 'ALL' ? logLevelFilter : undefined)
31
  ])
32
  setKomoditas(k); setStatus(s); setSchedules(sc); setLogs(l)
33
- // Sync running states from backend
 
 
 
 
 
 
 
 
 
34
  if (s?.running_tasks) {
35
- setRunningIds(prev => ({ ...prev, ...Object.fromEntries(Object.entries(s.running_tasks).map(([k, v]) => [Number(k), v])) }))
 
 
 
 
 
 
 
 
36
  }
37
  } catch {}
38
  }
39
 
40
  useEffect(() => {
41
  fetchData()
42
- const interval = setInterval(fetchData, 3000)
43
  return () => clearInterval(interval)
44
- }, [logLevelFilter])
45
 
46
  // Auto-scroll logs
47
  useEffect(() => {
@@ -53,23 +73,33 @@ export default function ForecastPage() {
53
 
54
  // Actions
55
  async function runOne(id: number) {
 
56
  setRunningIds(prev => ({ ...prev, [id]: true }))
57
  try {
58
  await api.runForecast(id)
59
  } catch (e: any) {
60
  alert(`❌ Gagal: ${e.message}`)
61
- setRunningIds(prev => ({ ...prev, [id]: false }))
 
 
62
  }
63
  }
64
 
65
  async function runAll() {
66
  setRunAllLoading(true)
67
- try { await api.runForecastAll() } catch (e: any) { alert(`❌ ${e.message}`) }
68
- setRunAllLoading(false)
 
 
 
 
69
  }
70
 
71
  async function stopForecast() {
72
- try { await api.stopForecast() } catch {}
 
 
 
73
  }
74
 
75
  async function handleAddSchedule(e: React.FormEvent) {
@@ -94,10 +124,32 @@ export default function ForecastPage() {
94
  } catch (e: any) { alert(`❌ ${e.message}`) }
95
  }
96
 
97
- const anyRunning = status?.any_forecast_running || runAllLoading
98
 
99
  return (
100
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  {/* LEFT: Controls */}
102
  <div className="lg:col-span-2 space-y-6">
103
  {/* Scheduler Status & Toggle */}
@@ -139,7 +191,6 @@ export default function ForecastPage() {
139
  </h2>
140
  <p className="text-xs text-slate-400 mb-4">Atur waktu forecast otomatis. Bisa tambah lebih dari satu jadwal.</p>
141
 
142
- {/* Quick presets */}
143
  <div className="flex flex-wrap gap-1.5 mb-4">
144
  <span className="text-[10px] font-semibold text-slate-500 px-2 self-center">Preset:</span>
145
  {[['01','00','Dini Hari'],['06','00','Pagi'],['12','00','Siang'],['18','00','Sore']].map(([h,m,l]) => (
@@ -148,7 +199,6 @@ export default function ForecastPage() {
148
  ))}
149
  </div>
150
 
151
- {/* Add form */}
152
  <form onSubmit={handleAddSchedule} className="grid grid-cols-12 gap-3 mb-5 bg-slate-900/40 p-4 rounded-xl border border-white/5">
153
  <div className="col-span-3">
154
  <label className="block text-[11px] font-medium text-slate-400 mb-1">Jam (WIB)</label>
@@ -174,7 +224,6 @@ export default function ForecastPage() {
174
  </div>
175
  </form>
176
 
177
- {/* Schedules table */}
178
  <table className="w-full text-left border-collapse">
179
  <thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
180
  <th className="pb-3 px-3">Label</th><th className="pb-3 px-3">Waktu</th><th className="pb-3 px-3">Next Run</th><th className="pb-3 px-3 text-right">Aksi</th>
@@ -210,29 +259,38 @@ export default function ForecastPage() {
210
  </h2>
211
  <div className="flex gap-2">
212
  <button onClick={runAll} disabled={anyRunning}
213
- className="bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-semibold py-2 px-4 rounded-xl text-xs flex items-center gap-2 disabled:opacity-50 transition active:scale-[0.98]">
214
- {anyRunning ? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Processing...</> : <><Play className="w-3.5 h-3.5 fill-current" /> Run Semua</>}
215
  </button>
216
  {anyRunning && (
217
  <button onClick={stopForecast}
218
  className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 font-medium py-2 px-4 rounded-xl text-xs flex items-center gap-2 border border-rose-500/30 transition animate-pulse">
219
- <Square className="w-3.5 h-3.5 fill-current" /> Stop
220
  </button>
221
  )}
222
  </div>
223
  </div>
224
- <div className="grid grid-cols-2 gap-3">
225
  {komoditas.map((k: any) => {
226
  const isRunning = runningIds[k.id] || status?.running_tasks?.[k.id]
227
  return (
228
  <div key={k.id} className="flex items-center justify-between bg-slate-900/50 rounded-xl px-4 py-3 border border-white/5 hover:border-white/10 transition">
229
  <span className="text-sm font-medium text-slate-200">{k.nama}</span>
230
- <button onClick={() => runOne(k.id)} disabled={isRunning || anyRunning}
231
- className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
232
- isRunning ? 'bg-amber-500/10 text-amber-400 border border-amber-500/20' : 'bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 disabled:opacity-30'
233
- }`}>
234
- {isRunning ? <><RefreshCw className="w-3 h-3 animate-spin" /> Running</> : <><Play className="w-3 h-3" /> Run</>}
235
- </button>
 
 
 
 
 
 
 
 
 
236
  </div>
237
  )
238
  })}
@@ -265,7 +323,7 @@ export default function ForecastPage() {
265
  if (log.level === 'ERROR') color = 'text-rose-400'
266
  const t = new Date(log.timestamp).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
267
  return (
268
- <div key={idx} className="leading-relaxed break-words hover:bg-white/[0.02] px-1 rounded transition-colors">
269
  <span className="text-slate-600 select-none">[{t}]</span>{' '}
270
  <span className={`font-semibold ${color} select-none`}>[{log.level}]</span>{' '}
271
  <span className="text-slate-500 select-none">({log.source})</span>{' '}
 
1
+ // app/dashboard/forecast/page.tsx — Forecast Management: Optimized for Real-time + Modals
2
  'use client'
3
  import { useEffect, useState, useRef } from 'react'
4
  import { api } from '@/lib/api'
5
+ import { Play, Square, RefreshCw, Calendar, Terminal, Plus, Trash2, CheckCircle2, XCircle } from 'lucide-react'
6
 
7
  interface LogItem { timestamp: string; level: string; source: string; message: string }
8
 
 
14
  const [runningIds, setRunningIds] = useState<Record<number, boolean>>({})
15
  const [runAllLoading, setRunAllLoading] = useState(false)
16
  const [logLevelFilter, setLogLevelFilter] = useState('ALL')
17
+ const [showSuccessModal, setShowSuccessModal] = useState(false)
18
+ const [isFinishing, setIsFinishing] = useState(false)
19
 
20
  // Schedule form
21
  const [selectedHour, setSelectedHour] = useState('01')
 
24
 
25
  const logsContainerRef = useRef<HTMLDivElement>(null)
26
  const prevLogsLengthRef = useRef(0)
27
+ const wasRunningRef = useRef(false)
28
 
29
  const fetchData = async () => {
30
  try {
 
33
  api.getLogs(200, logLevelFilter !== 'ALL' ? logLevelFilter : undefined)
34
  ])
35
  setKomoditas(k); setStatus(s); setSchedules(sc); setLogs(l)
36
+
37
+ const isCurrentlyRunning = s?.any_forecast_running || Object.keys(s?.running_tasks || {}).length > 0
38
+
39
+ // Detect completion to show modal
40
+ if (wasRunningRef.current && !isCurrentlyRunning && !runAllLoading) {
41
+ setShowSuccessModal(true)
42
+ }
43
+ wasRunningRef.current = isCurrentlyRunning
44
+
45
+ // Sync running states from backend, but keep optimistic UI if it's still running
46
  if (s?.running_tasks) {
47
+ const backendRunning = Object.fromEntries(Object.entries(s.running_tasks).map(([k, v]) => [Number(k), v]))
48
+ setRunningIds(prev => {
49
+ const newState = { ...prev, ...backendRunning }
50
+ // Remove items that backend says are not running anymore
51
+ Object.keys(prev).forEach(id => {
52
+ if (!backendRunning[Number(id)]) delete newState[Number(id)]
53
+ })
54
+ return newState
55
+ })
56
  }
57
  } catch {}
58
  }
59
 
60
  useEffect(() => {
61
  fetchData()
62
+ const interval = setInterval(fetchData, 1500) // Faster polling
63
  return () => clearInterval(interval)
64
+ }, [logLevelFilter, runAllLoading])
65
 
66
  // Auto-scroll logs
67
  useEffect(() => {
 
73
 
74
  // Actions
75
  async function runOne(id: number) {
76
+ // Optimistic UI
77
  setRunningIds(prev => ({ ...prev, [id]: true }))
78
  try {
79
  await api.runForecast(id)
80
  } catch (e: any) {
81
  alert(`❌ Gagal: ${e.message}`)
82
+ setRunningIds(prev => {
83
+ const next = { ...prev }; delete next[id]; return next
84
+ })
85
  }
86
  }
87
 
88
  async function runAll() {
89
  setRunAllLoading(true)
90
+ try {
91
+ await api.runForecastAll()
92
+ } catch (e: any) {
93
+ alert(`❌ ${e.message}`)
94
+ setRunAllLoading(false)
95
+ }
96
  }
97
 
98
  async function stopForecast() {
99
+ try {
100
+ await api.stopForecast()
101
+ setRunAllLoading(false)
102
+ } catch {}
103
  }
104
 
105
  async function handleAddSchedule(e: React.FormEvent) {
 
124
  } catch (e: any) { alert(`❌ ${e.message}`) }
125
  }
126
 
127
+ const anyRunning = status?.any_forecast_running || runAllLoading || Object.keys(runningIds).length > 0
128
 
129
  return (
130
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 relative">
131
+
132
+ {/* SUCCESS MODAL */}
133
+ {showSuccessModal && (
134
+ <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-fade-in">
135
+ <div className="glass-panel max-w-sm w-full rounded-3xl p-8 text-center border border-teal-500/30 shadow-[0_0_50px_rgba(45,212,191,0.2)] animate-slide-up">
136
+ <div className="w-20 h-20 bg-teal-500/20 rounded-full flex items-center justify-center mx-auto mb-6 border border-teal-500/30">
137
+ <CheckCircle2 className="w-10 h-10 text-teal-400" />
138
+ </div>
139
+ <h3 className="text-2xl font-bold text-white mb-2">Selesai!</h3>
140
+ <p className="text-slate-400 text-sm mb-8">
141
+ Proses prediksi harga komoditas telah berhasil diselesaikan seluruhnya.
142
+ </p>
143
+ <button
144
+ onClick={() => setShowSuccessModal(false)}
145
+ className="w-full py-3 bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-bold rounded-xl transition shadow-lg shadow-teal-500/20"
146
+ >
147
+ Mantap
148
+ </button>
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
  {/* LEFT: Controls */}
154
  <div className="lg:col-span-2 space-y-6">
155
  {/* Scheduler Status & Toggle */}
 
191
  </h2>
192
  <p className="text-xs text-slate-400 mb-4">Atur waktu forecast otomatis. Bisa tambah lebih dari satu jadwal.</p>
193
 
 
194
  <div className="flex flex-wrap gap-1.5 mb-4">
195
  <span className="text-[10px] font-semibold text-slate-500 px-2 self-center">Preset:</span>
196
  {[['01','00','Dini Hari'],['06','00','Pagi'],['12','00','Siang'],['18','00','Sore']].map(([h,m,l]) => (
 
199
  ))}
200
  </div>
201
 
 
202
  <form onSubmit={handleAddSchedule} className="grid grid-cols-12 gap-3 mb-5 bg-slate-900/40 p-4 rounded-xl border border-white/5">
203
  <div className="col-span-3">
204
  <label className="block text-[11px] font-medium text-slate-400 mb-1">Jam (WIB)</label>
 
224
  </div>
225
  </form>
226
 
 
227
  <table className="w-full text-left border-collapse">
228
  <thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
229
  <th className="pb-3 px-3">Label</th><th className="pb-3 px-3">Waktu</th><th className="pb-3 px-3">Next Run</th><th className="pb-3 px-3 text-right">Aksi</th>
 
259
  </h2>
260
  <div className="flex gap-2">
261
  <button onClick={runAll} disabled={anyRunning}
262
+ className={`bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-semibold py-2 px-4 rounded-xl text-xs flex items-center gap-2 transition active:scale-[0.98] ${anyRunning ? 'opacity-50 cursor-not-allowed' : ''}`}>
263
+ {runAllLoading ? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Processing...</> : <><Play className="w-3.5 h-3.5 fill-current" /> Run Semua</>}
264
  </button>
265
  {anyRunning && (
266
  <button onClick={stopForecast}
267
  className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 font-medium py-2 px-4 rounded-xl text-xs flex items-center gap-2 border border-rose-500/30 transition animate-pulse">
268
+ <Square className="w-3.5 h-3.5 fill-current" /> Stop Semua
269
  </button>
270
  )}
271
  </div>
272
  </div>
273
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
274
  {komoditas.map((k: any) => {
275
  const isRunning = runningIds[k.id] || status?.running_tasks?.[k.id]
276
  return (
277
  <div key={k.id} className="flex items-center justify-between bg-slate-900/50 rounded-xl px-4 py-3 border border-white/5 hover:border-white/10 transition">
278
  <span className="text-sm font-medium text-slate-200">{k.nama}</span>
279
+ <div className="flex gap-1.5">
280
+ {isRunning ? (
281
+ <button onClick={stopForecast}
282
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 transition">
283
+ <Square className="w-3 h-3 fill-current" /> Stop
284
+ </button>
285
+ ) : (
286
+ <button onClick={() => runOne(k.id)} disabled={anyRunning}
287
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
288
+ isRunning ? 'bg-amber-500/10 text-amber-400 border border-amber-500/20' : 'bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 disabled:opacity-30'
289
+ }`}>
290
+ {isRunning ? <><RefreshCw className="w-3 h-3 animate-spin" /> Running</> : <><Play className="w-3 h-3" /> Run</>}
291
+ </button>
292
+ )}
293
+ </div>
294
  </div>
295
  )
296
  })}
 
323
  if (log.level === 'ERROR') color = 'text-rose-400'
324
  const t = new Date(log.timestamp).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
325
  return (
326
+ <div key={idx} className="leading-relaxed break-words hover:bg-white/[0.02] px-1 rounded transition-colors animate-fade-in">
327
  <span className="text-slate-600 select-none">[{t}]</span>{' '}
328
  <span className={`font-semibold ${color} select-none`}>[{log.level}]</span>{' '}
329
  <span className="text-slate-500 select-none">({log.source})</span>{' '}
frontend/app/dashboard/layout.tsx CHANGED
@@ -44,7 +44,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
44
  } catch {}
45
  }
46
  fetchStatus()
47
- const interval = setInterval(fetchStatus, 5000)
48
  return () => clearInterval(interval)
49
  }, [])
50
 
@@ -55,7 +55,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
55
 
56
  const links = [
57
  { href: '/dashboard', icon: Home, label: 'Overview' },
58
- { href: '/dashboard/prediksi', icon: BarChart2, label: 'Prediksi' },
59
  { href: '/dashboard/forecast', icon: Terminal, label: 'Forecast' },
60
  { href: '/dashboard/settings', icon: Settings, label: 'Pengaturan' },
61
  ]
 
44
  } catch {}
45
  }
46
  fetchStatus()
47
+ const interval = setInterval(fetchStatus, 1500)
48
  return () => clearInterval(interval)
49
  }, [])
50
 
 
55
 
56
  const links = [
57
  { href: '/dashboard', icon: Home, label: 'Overview' },
 
58
  { href: '/dashboard/forecast', icon: Terminal, label: 'Forecast' },
59
  { href: '/dashboard/settings', icon: Settings, label: 'Pengaturan' },
60
  ]
frontend/app/dashboard/prediksi/page.tsx DELETED
@@ -1,244 +0,0 @@
1
- // app/dashboard/prediksi/page.tsx — Prediksi data + Model Comparison
2
- 'use client'
3
- import { useEffect, useState } from 'react'
4
- import { api } from '@/lib/api'
5
- import { Pencil, Trash2, Plus, Check, X, BarChart3, TrendingUp, Shield, Layers } from 'lucide-react'
6
-
7
- export default function PrediksiPage() {
8
- const [komoditas, setKomoditas] = useState<any[]>([])
9
- const [selected, setSelected] = useState<number | null>(null)
10
- const [data, setData] = useState<any>(null)
11
- const [editInsight, setEditInsight] = useState<any>(null)
12
- const [newInsight, setNewInsight] = useState(false)
13
- const [form, setForm] = useState({ konten: '', tipe: 'positif', ikon: 'check-circle', urutan: 1 })
14
- const [editRingkasan, setEditRingkasan] = useState(false)
15
- const [ringForm, setRingForm] = useState({ status_analisis: '', deskripsi_status: '' })
16
- const [editModel, setEditModel] = useState(false)
17
- const [modelForm, setModelForm] = useState({ deskripsi: '', catatan_validasi: '' })
18
-
19
- useEffect(() => { api.getKomoditas().then(setKomoditas) }, [])
20
-
21
- async function selectKomoditas(id: number) {
22
- setSelected(id)
23
- const d = await api.getPrediksi(id)
24
- setData(d)
25
- setRingForm({ status_analisis: d.ringkasan?.status_analisis || '', deskripsi_status: d.ringkasan?.deskripsi_status || '' })
26
- setModelForm({ deskripsi: d.model?.deskripsi || '', catatan_validasi: d.model?.catatan_validasi || '' })
27
- }
28
-
29
- async function saveInsightEdit() { await api.updateInsight(editInsight.id, form); setEditInsight(null); selectKomoditas(selected!) }
30
- async function saveNewInsight() { await api.addInsight(selected!, form); setNewInsight(false); setForm({ konten: '', tipe: 'positif', ikon: 'check-circle', urutan: 1 }); selectKomoditas(selected!) }
31
- async function deleteInsight(id: number) { if (!confirm('Hapus insight ini?')) return; await api.deleteInsight(id); selectKomoditas(selected!) }
32
- async function saveRingkasan() { await api.updateRingkasan(selected!, ringForm); setEditRingkasan(false); selectKomoditas(selected!) }
33
- async function saveModel() { await api.updateModelMl(selected!, modelForm); setEditModel(false); selectKomoditas(selected!) }
34
-
35
- const tipeColor: Record<string, string> = {
36
- positif: 'bg-teal-500/10 text-teal-400 border-teal-500/20',
37
- negatif: 'bg-rose-500/10 text-rose-400 border-rose-500/20',
38
- netral: 'bg-slate-700/50 text-slate-300 border-slate-600/30',
39
- }
40
-
41
- function metricColor(val: number, type: 'mape' | 'r2') {
42
- if (type === 'mape') return val < 5 ? 'text-teal-400' : val < 15 ? 'text-amber-400' : 'text-rose-400'
43
- return val > 0.8 ? 'text-teal-400' : val > 0.5 ? 'text-amber-400' : 'text-rose-400'
44
- }
45
-
46
- return (
47
- <div className="space-y-6">
48
- <h1 className="text-2xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">Data Prediksi</h1>
49
-
50
- {/* Komoditas selector */}
51
- <div className="flex gap-2 flex-wrap">
52
- {komoditas.map((k: any) => (
53
- <button key={k.id} onClick={() => selectKomoditas(k.id)}
54
- className={`px-4 py-2 rounded-xl text-xs font-medium border transition ${
55
- selected === k.id ? 'bg-gradient-to-r from-teal-500/20 to-indigo-500/20 border-teal-500/30 text-white' : 'border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200'
56
- }`}>{k.nama}</button>
57
- ))}
58
- </div>
59
-
60
- {data && (
61
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in">
62
-
63
- {/* Model Comparison (NEW) */}
64
- {data.all_models && data.all_models.length > 0 && (
65
- <div className="lg:col-span-2 glass-panel rounded-2xl p-6 border border-white/5">
66
- <h2 className="text-base font-bold text-slate-100 flex items-center gap-2 mb-4">
67
- <Layers className="w-5 h-5 text-indigo-400" /> Perbandingan Model
68
- </h2>
69
- <div className="overflow-x-auto">
70
- <table className="w-full text-left border-collapse">
71
- <thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
72
- <th className="pb-3 px-3">Model</th><th className="pb-3 px-3">RMSE</th><th className="pb-3 px-3">MAPE (%)</th><th className="pb-3 px-3">MAE</th><th className="pb-3 px-3">R²</th><th className="pb-3 px-3">Status</th>
73
- </tr></thead>
74
- <tbody className="divide-y divide-slate-800/60 text-sm">
75
- {data.all_models.map((m: any, i: number) => (
76
- <tr key={i} className={`hover:bg-slate-800/30 transition ${m.is_active ? 'bg-teal-500/5' : ''}`}>
77
- <td className="py-3 px-3 font-medium text-slate-200">{m.nama_model}</td>
78
- <td className="py-3 px-3 font-mono text-xs text-slate-300">{Number(m.rmse).toFixed(2)}</td>
79
- <td className={`py-3 px-3 font-mono text-xs font-bold ${metricColor(m.mape, 'mape')}`}>{Number(m.mape).toFixed(2)}%</td>
80
- <td className="py-3 px-3 font-mono text-xs text-slate-300">{Number(m.mae).toFixed(2)}</td>
81
- <td className={`py-3 px-3 font-mono text-xs font-bold ${metricColor(m.r2_score, 'r2')}`}>{Number(m.r2_score).toFixed(4)}</td>
82
- <td className="py-3 px-3">
83
- {m.is_active ? (
84
- <span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-gradient-to-r from-teal-500/20 to-indigo-500/20 text-teal-400 border border-teal-500/20">🏆 BEST</span>
85
- ) : <span className="text-xs text-slate-500">Runner-up</span>}
86
- </td>
87
- </tr>
88
- ))}
89
- </tbody>
90
- </table>
91
- </div>
92
- </div>
93
- )}
94
-
95
- {/* Ringkasan */}
96
- <div className="glass-panel rounded-2xl p-6 border border-white/5">
97
- <div className="flex justify-between items-center mb-3">
98
- <h2 className="font-semibold flex items-center gap-2"><TrendingUp className="w-4 h-4 text-teal-400" /> Ringkasan</h2>
99
- <button onClick={() => setEditRingkasan(!editRingkasan)} className="text-slate-400 hover:text-white"><Pencil size={15} /></button>
100
- </div>
101
- {!editRingkasan ? (
102
- <div className="space-y-3 text-sm">
103
- <div className="flex justify-between"><span className="text-slate-400">Rentang Harga</span><span className="font-mono">Rp {data.ringkasan?.harga_min?.toLocaleString()} – {data.ringkasan?.harga_max?.toLocaleString()}</span></div>
104
- <div className="flex justify-between"><span className="text-slate-400">Tren</span><span>{data.ringkasan?.tren}</span></div>
105
- <div className="flex justify-between"><span className="text-slate-400">Status</span><span>{data.ringkasan?.status_analisis}</span></div>
106
- <p className="text-slate-500 text-xs mt-2 bg-slate-900/50 rounded-lg p-3 border border-white/5">{data.ringkasan?.deskripsi_status}</p>
107
- </div>
108
- ) : (
109
- <div className="space-y-3">
110
- <div><label className="text-xs text-slate-400">Status Analisis</label>
111
- <input value={ringForm.status_analisis} onChange={e => setRingForm({ ...ringForm, status_analisis: e.target.value })}
112
- className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
113
- <div><label className="text-xs text-slate-400">Deskripsi</label>
114
- <textarea value={ringForm.deskripsi_status} onChange={e => setRingForm({ ...ringForm, deskripsi_status: e.target.value })}
115
- rows={3} className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
116
- <div className="flex gap-2">
117
- <button onClick={saveRingkasan} className="flex items-center gap-1 px-3 py-1.5 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Simpan</button>
118
- <button onClick={() => setEditRingkasan(false)} className="flex items-center gap-1 px-3 py-1.5 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
119
- </div>
120
- </div>
121
- )}
122
- </div>
123
-
124
- {/* Model ML */}
125
- <div className="glass-panel rounded-2xl p-6 border border-white/5">
126
- <div className="flex justify-between items-center mb-3">
127
- <h2 className="font-semibold flex items-center gap-2"><Shield className="w-4 h-4 text-indigo-400" /> Model Aktif</h2>
128
- <button onClick={() => setEditModel(!editModel)} className="text-slate-400 hover:text-white"><Pencil size={15} /></button>
129
- </div>
130
- {!editModel ? (
131
- <div className="space-y-3 text-sm">
132
- <div className="flex justify-between"><span className="text-slate-400">Model</span><span className="font-bold text-teal-400">{data.model?.nama_model}</span></div>
133
- <div className="flex justify-between"><span className="text-slate-400">MAPE</span><span className="font-mono">{data.model?.mape}%</span></div>
134
- <div className="flex justify-between"><span className="text-slate-400">RMSE</span><span className="font-mono">{data.model?.rmse}</span></div>
135
- <div className="flex justify-between"><span className="text-slate-400">Stabilitas</span>
136
- <span className="uppercase text-[11px] font-bold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">{data.model?.stabilitas}</span>
137
- </div>
138
- <p className="text-slate-500 text-xs mt-2">{data.model?.deskripsi}</p>
139
- </div>
140
- ) : (
141
- <div className="space-y-3">
142
- <div><label className="text-xs text-slate-400">Deskripsi</label>
143
- <textarea value={modelForm.deskripsi} onChange={e => setModelForm({ ...modelForm, deskripsi: e.target.value })}
144
- rows={3} className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
145
- <div><label className="text-xs text-slate-400">Catatan Validasi</label>
146
- <textarea value={modelForm.catatan_validasi} onChange={e => setModelForm({ ...modelForm, catatan_validasi: e.target.value })}
147
- rows={2} className="w-full mt-1 glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" /></div>
148
- <div className="flex gap-2">
149
- <button onClick={saveModel} className="flex items-center gap-1 px-3 py-1.5 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Simpan</button>
150
- <button onClick={() => setEditModel(false)} className="flex items-center gap-1 px-3 py-1.5 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
151
- </div>
152
- </div>
153
- )}
154
- </div>
155
-
156
- {/* Insights */}
157
- <div className="lg:col-span-2 glass-panel rounded-2xl p-6 border border-white/5">
158
- <div className="flex justify-between items-center mb-4">
159
- <h2 className="font-semibold flex items-center gap-2"><BarChart3 className="w-4 h-4 text-amber-400" /> Insight Prediktif</h2>
160
- <button onClick={() => setNewInsight(true)} className="flex items-center gap-1 px-3 py-1.5 bg-teal-500 hover:bg-teal-400 text-slate-900 rounded-lg text-xs font-bold">
161
- <Plus size={13} /> Tambah
162
- </button>
163
- </div>
164
- <div className="space-y-3">
165
- {(data.insights || []).map((ins: any) => (
166
- <div key={ins.id}>
167
- {editInsight?.id === ins.id ? (
168
- <div className="bg-slate-900/60 rounded-xl p-4 space-y-2 border border-white/5">
169
- <textarea value={form.konten} onChange={e => setForm({ ...form, konten: e.target.value })} rows={2}
170
- className="w-full glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" />
171
- <div className="flex gap-2">
172
- <select value={form.tipe} onChange={e => setForm({ ...form, tipe: e.target.value })} className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none">
173
- <option value="positif">positif</option><option value="negatif">negatif</option><option value="netral">netral</option>
174
- </select>
175
- <input value={form.ikon} onChange={e => setForm({ ...form, ikon: e.target.value })} placeholder="ikon" className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none flex-1" />
176
- <input type="number" value={form.urutan} onChange={e => setForm({ ...form, urutan: parseInt(e.target.value) })} className="w-16 glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none" />
177
- </div>
178
- <div className="flex gap-2">
179
- <button onClick={saveInsightEdit} className="flex items-center gap-1 px-3 py-1 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Simpan</button>
180
- <button onClick={() => setEditInsight(null)} className="flex items-center gap-1 px-3 py-1 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
181
- </div>
182
- </div>
183
- ) : (
184
- <div className="flex items-start justify-between bg-slate-900/50 rounded-xl px-4 py-3 border border-white/5 hover:border-white/10 transition">
185
- <div className="flex items-start gap-3">
186
- <span className={`text-[11px] px-2.5 py-0.5 rounded-full mt-0.5 font-bold border ${tipeColor[ins.tipe] || tipeColor.netral}`}>{ins.tipe}</span>
187
- <p className="text-sm text-slate-200">{ins.konten}</p>
188
- </div>
189
- <div className="flex gap-2 ml-4 shrink-0">
190
- <button onClick={() => { setEditInsight(ins); setForm({ konten: ins.konten, tipe: ins.tipe, ikon: ins.ikon, urutan: ins.urutan }) }} className="text-slate-400 hover:text-white"><Pencil size={14} /></button>
191
- <button onClick={() => deleteInsight(ins.id)} className="text-slate-400 hover:text-rose-400"><Trash2 size={14} /></button>
192
- </div>
193
- </div>
194
- )}
195
- </div>
196
- ))}
197
- {newInsight && (
198
- <div className="bg-slate-900/60 rounded-xl p-4 space-y-2 border border-teal-500/20">
199
- <textarea value={form.konten} onChange={e => setForm({ ...form, konten: e.target.value })} rows={2} placeholder="Isi insight..."
200
- className="w-full glass-input rounded-lg px-3 py-2 text-sm text-white focus:outline-none" />
201
- <div className="flex gap-2">
202
- <select value={form.tipe} onChange={e => setForm({ ...form, tipe: e.target.value })} className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none">
203
- <option value="positif">positif</option><option value="negatif">negatif</option><option value="netral">netral</option>
204
- </select>
205
- <input value={form.ikon} onChange={e => setForm({ ...form, ikon: e.target.value })} placeholder="ikon" className="glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none flex-1" />
206
- <input type="number" value={form.urutan} onChange={e => setForm({ ...form, urutan: parseInt(e.target.value) })} className="w-16 glass-input rounded-lg px-2 py-1 text-xs text-white focus:outline-none" />
207
- </div>
208
- <div className="flex gap-2">
209
- <button onClick={saveNewInsight} className="flex items-center gap-1 px-3 py-1 bg-teal-500 text-slate-900 rounded-lg text-xs font-bold"><Check size={12} />Tambah</button>
210
- <button onClick={() => setNewInsight(false)} className="flex items-center gap-1 px-3 py-1 bg-slate-700 rounded-lg text-xs"><X size={12} />Batal</button>
211
- </div>
212
- </div>
213
- )}
214
- </div>
215
- </div>
216
-
217
- {/* Prediksi 7 Hari */}
218
- <div className="lg:col-span-2 glass-panel rounded-2xl p-6 border border-white/5">
219
- <h2 className="font-semibold mb-4 flex items-center gap-2">
220
- <BarChart3 className="w-4 h-4 text-teal-400" /> Hasil Prediksi 7 Hari
221
- <span className="text-slate-500 text-xs font-normal ml-1">(read-only)</span>
222
- </h2>
223
- <div className="overflow-x-auto">
224
- <table className="w-full text-left border-collapse">
225
- <thead><tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
226
- <th className="pb-3 px-3">Tanggal</th><th className="pb-3 px-3 text-right">Harga Prediksi</th><th className="pb-3 px-3 text-right">Confidence</th>
227
- </tr></thead>
228
- <tbody className="divide-y divide-slate-800/60 text-sm">
229
- {(data.prediksi_7hari || []).map((p: any, i: number) => (
230
- <tr key={i} className="hover:bg-slate-800/30 transition">
231
- <td className="py-3 px-3 text-slate-300">{p.tanggal_target}</td>
232
- <td className="py-3 px-3 text-right font-mono font-bold text-white">Rp {Number(p.harga_prediksi).toLocaleString()}</td>
233
- <td className="py-3 px-3 text-right text-slate-400">{p.confidence_level}%</td>
234
- </tr>
235
- ))}
236
- </tbody>
237
- </table>
238
- </div>
239
- </div>
240
- </div>
241
- )}
242
- </div>
243
- )
244
- }