Spaces:
Sleeping
Sleeping
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:
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
if (s?.running_tasks) {
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
} catch {}
|
| 38 |
}
|
| 39 |
|
| 40 |
useEffect(() => {
|
| 41 |
fetchData()
|
| 42 |
-
const interval = setInterval(fetchData,
|
| 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 =>
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
}
|
| 64 |
|
| 65 |
async function runAll() {
|
| 66 |
setRunAllLoading(true)
|
| 67 |
-
try {
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
async function stopForecast() {
|
| 72 |
-
try {
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 214 |
-
{
|
| 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 |
-
<
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|