anoderb commited on
Commit
61839a2
·
1 Parent(s): 0cc435f

feat: add search, filter, and pagination to AI Center landing page table

Browse files
Files changed (1) hide show
  1. frontend/app/page.tsx +306 -133
frontend/app/page.tsx CHANGED
@@ -27,7 +27,10 @@ import {
27
  CheckCircle2,
28
  ChevronDown,
29
  ChevronUp,
30
- Copy
 
 
 
31
  } from 'lucide-react'
32
 
33
  // Auto-detect base path/URL if running exported on FastAPI or standalone Next.js
@@ -187,6 +190,11 @@ export default function Dashboard() {
187
  const [activeModelsSummary, setActiveModelsSummary] = useState<any>(null)
188
  const [loadingSummary, setLoadingSummary] = useState(false)
189
  const [selectedConfigCommodityId, setSelectedConfigCommodityId] = useState<number | null>(null)
 
 
 
 
 
190
 
191
  // --- Global States ---
192
  const [currentTime, setCurrentTime] = useState('')
@@ -1450,146 +1458,311 @@ export default function Dashboard() {
1450
 
1451
  {/* Table Card */}
1452
  <div className="bg-white rounded-3xl shadow-sm border border-slate-200/60 overflow-hidden">
1453
- <div className="px-6 py-5 border-b border-slate-100 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
1454
- <h3 className="font-black text-slate-800 text-base tracking-tight">Daftar Model Machine Learning</h3>
1455
- <div className="flex gap-2">
1456
- <span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-[10px] font-bold text-slate-600 shadow-sm">
1457
- <span className="w-2 h-2 rounded-full bg-emerald-500"></span> Active Models Only
 
1458
  </span>
1459
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1460
  </div>
1461
 
1462
- <div className="overflow-x-auto">
1463
- <table className="w-full text-left border-collapse">
1464
- <thead>
1465
- <tr className="border-b border-slate-200/60 bg-slate-50/30">
1466
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">Komoditas & Model</th>
1467
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">MAPE (%)</th>
1468
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">RMSE</th>
1469
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">MAE</th>
1470
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">R² Score</th>
1471
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider text-center">Status Validasi</th>
1472
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider text-center">Status</th>
1473
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider text-right">Aksi</th>
1474
- </tr>
1475
- </thead>
1476
- <tbody className="divide-y divide-slate-100">
1477
- {loadingSummary && !activeModelsSummary ? (
1478
- <tr>
1479
- <td colSpan={8} className="text-center py-16">
1480
- <RefreshCw className="w-8 h-8 animate-spin text-slate-400 mx-auto mb-2" />
1481
- <p className="text-xs text-slate-500 font-medium">Memuat summary model...</p>
1482
- </td>
1483
- </tr>
1484
- ) : !activeModelsSummary || activeModelsSummary.commodity_models?.length === 0 ? (
1485
- <tr>
1486
- <td colSpan={8} className="text-center py-16 text-slate-400 italic text-xs">
1487
- Belum ada model machine learning yang dilatih.
1488
- </td>
1489
- </tr>
1490
- ) : (
1491
- activeModelsSummary.commodity_models.map((item: any) => {
1492
- const isRunning = forecastRunningIds[item.komoditas_id] || forecastStatus?.running_tasks?.[item.komoditas_id];
1493
- return (
1494
- <tr key={item.komoditas_id} className="hover:bg-slate-50/50 transition-colors group">
1495
- <td className="px-6 py-4">
1496
- <div className="flex items-center gap-3">
1497
- <div className="w-10 h-10 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center shrink-0 border border-indigo-100">
1498
- <Activity className="w-5 h-5 text-indigo-500" />
1499
- </div>
1500
- <div>
1501
- <p className="text-xs font-black text-slate-900">{item.komoditas_nama}</p>
1502
- <p className="text-[10px] font-bold text-primary mt-0.5 tracking-wide">
1503
- {item.nama_model ? item.nama_model : 'Belum Latih Model'}
1504
- {item.nama_model && <span className="text-slate-400 font-medium ml-1">v1.0</span>}
1505
- </p>
1506
- </div>
1507
- </div>
1508
- </td>
1509
- <td className="px-6 py-4 text-xs font-semibold text-slate-700">
1510
- {item.mape !== null ? (
1511
- <span className={`font-mono font-bold ${item.mape < 10 ? 'text-emerald-600' : (item.mape < 20 ? 'text-amber-600' : 'text-rose-600')}`}>
1512
- {Number(item.mape).toFixed(2)}%
1513
- </span>
1514
- ) : '-'}
1515
- </td>
1516
- <td className="px-6 py-4 text-xs font-mono text-slate-600">
1517
- {item.rmse !== null ? Number(item.rmse).toLocaleString('id-ID', { maximumFractionDigits: 2 }) : '-'}
1518
- </td>
1519
- <td className="px-6 py-4 text-xs font-mono text-slate-600">
1520
- {item.mae !== null ? Number(item.mae).toLocaleString('id-ID', { maximumFractionDigits: 2 }) : '-'}
1521
- </td>
1522
- <td className="px-6 py-4 text-xs font-mono text-slate-600">
1523
- {item.r2_score !== null ? Number(item.r2_score).toFixed(4) : '-'}
1524
- </td>
1525
- <td className="px-6 py-4 text-center">
1526
- {item.nama_model ? (
1527
- <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-[10px] font-bold border border-emerald-100">
1528
- Terverifikasi
1529
- </span>
1530
- ) : (
1531
- <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded bg-slate-100 text-slate-500 text-[10px] font-bold border border-slate-200">
1532
- Belum Latih
1533
- </span>
1534
- )}
1535
- </td>
1536
- <td className="px-6 py-4 text-center">
1537
- {item.is_active ? (
1538
- <span className="relative flex items-center justify-center h-2.5 w-2.5 mx-auto" title="Model Aktif">
1539
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
1540
- <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
1541
- </span>
1542
- ) : (
1543
- <span className="inline-block w-2.5 h-2.5 rounded-full bg-slate-300 mx-auto" title="Tidak Aktif"></span>
1544
- )}
1545
- </td>
1546
- <td className="px-6 py-4 text-right">
1547
- <div className="flex items-center justify-end gap-1.5">
1548
- <button
1549
- onClick={() => handleOpenDetailModal(item.komoditas_id)}
1550
- disabled={!item.nama_model || loadingDetailId === item.komoditas_id}
1551
- className="w-8 h-8 rounded-lg bg-slate-50 text-slate-500 hover:text-primary hover:bg-blue-50 flex items-center justify-center transition-all border border-slate-200 hover:border-blue-200 disabled:opacity-30 disabled:pointer-events-none"
1552
- title="Detail Performa"
1553
- >
1554
- <Info className="w-3.5 h-3.5" />
1555
- </button>
1556
- <button
1557
- onClick={() => {
1558
- setSelectedConfigCommodityId(item.komoditas_id);
1559
- setActiveTab('settings');
1560
- }}
1561
- className="w-8 h-8 rounded-lg bg-slate-50 text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 flex items-center justify-center transition-all border border-slate-200 hover:border-indigo-200"
1562
- title="Konfigurasi ML"
1563
- >
1564
- <Sliders className="w-3.5 h-3.5" />
1565
- </button>
1566
- {isRunning ? (
1567
- <button
1568
- onClick={handleStopForecast}
1569
- className="w-8 h-8 rounded-lg bg-rose-50 text-rose-600 flex items-center justify-center border border-rose-100 animate-pulse"
1570
- title="Stop Training"
1571
- >
1572
- <Square className="w-3.5 h-3.5 fill-current" />
1573
- </button>
1574
- ) : (
1575
- <button
1576
- onClick={() => handleRunForecast(item.komoditas_id)}
1577
- disabled={isForecastRunning}
1578
- className="w-8 h-8 rounded-lg bg-slate-50 text-slate-500 hover:text-emerald-600 hover:bg-emerald-50 flex items-center justify-center transition-all border border-slate-200 hover:border-emerald-200 disabled:opacity-30"
1579
- title="Retrain Model"
1580
  >
1581
- <Play className="w-3.5 h-3.5 fill-current" />
1582
  </button>
1583
  )}
1584
- </div>
1585
- </td>
1586
- </tr>
1587
- )
1588
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1589
  )}
1590
- </tbody>
1591
- </table>
1592
- </div>
1593
  </div>
1594
  </div>
1595
  )}
 
27
  CheckCircle2,
28
  ChevronDown,
29
  ChevronUp,
30
+ Copy,
31
+ Search,
32
+ ChevronLeft,
33
+ ChevronRight
34
  } from 'lucide-react'
35
 
36
  // Auto-detect base path/URL if running exported on FastAPI or standalone Next.js
 
190
  const [activeModelsSummary, setActiveModelsSummary] = useState<any>(null)
191
  const [loadingSummary, setLoadingSummary] = useState(false)
192
  const [selectedConfigCommodityId, setSelectedConfigCommodityId] = useState<number | null>(null)
193
+ const [aiSearchQuery, setAiSearchQuery] = useState('')
194
+ const [aiFilterStatus, setAiFilterStatus] = useState<'all' | 'trained' | 'untrained'>('all')
195
+ const [aiFilterModel, setAiFilterModel] = useState<string>('all')
196
+ const [aiCurrentPage, setAiCurrentPage] = useState(1)
197
+ const aiRowsPerPage = 10
198
 
199
  // --- Global States ---
200
  const [currentTime, setCurrentTime] = useState('')
 
1458
 
1459
  {/* Table Card */}
1460
  <div className="bg-white rounded-3xl shadow-sm border border-slate-200/60 overflow-hidden">
1461
+ {/* Search, Filter & Header Bar */}
1462
+ <div className="px-6 py-5 border-b border-slate-100 bg-slate-50/50">
1463
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
1464
+ <h3 className="font-black text-slate-800 text-base tracking-tight">Daftar Model Machine Learning</h3>
1465
+ <span className="text-[10px] font-bold text-slate-500 bg-white px-3 py-1.5 rounded-lg border border-slate-200 shadow-sm">
1466
+ {activeModelsSummary?.commodity_models?.length || 0} komoditas terdaftar
1467
  </span>
1468
  </div>
1469
+
1470
+ {/* Search & Filter Row */}
1471
+ <div className="flex flex-col sm:flex-row gap-3">
1472
+ {/* Search Input */}
1473
+ <div className="relative flex-1">
1474
+ <Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
1475
+ <input
1476
+ type="text"
1477
+ value={aiSearchQuery}
1478
+ onChange={(e) => { setAiSearchQuery(e.target.value); setAiCurrentPage(1); }}
1479
+ placeholder="Cari komoditas atau nama model..."
1480
+ className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white text-xs text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
1481
+ />
1482
+ {aiSearchQuery && (
1483
+ <button onClick={() => { setAiSearchQuery(''); setAiCurrentPage(1); }} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
1484
+ <X className="w-3.5 h-3.5" />
1485
+ </button>
1486
+ )}
1487
+ </div>
1488
+
1489
+ {/* Filter: Status */}
1490
+ <div className="flex gap-2">
1491
+ <select
1492
+ value={aiFilterStatus}
1493
+ onChange={(e) => { setAiFilterStatus(e.target.value as any); setAiCurrentPage(1); }}
1494
+ className="px-3 py-2.5 rounded-xl border border-slate-200 bg-white text-xs font-semibold text-slate-700 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary cursor-pointer"
1495
+ >
1496
+ <option value="all">Semua Status</option>
1497
+ <option value="trained">Sudah Dilatih</option>
1498
+ <option value="untrained">Belum Dilatih</option>
1499
+ </select>
1500
+
1501
+ {/* Filter: Model Type */}
1502
+ <select
1503
+ value={aiFilterModel}
1504
+ onChange={(e) => { setAiFilterModel(e.target.value); setAiCurrentPage(1); }}
1505
+ className="px-3 py-2.5 rounded-xl border border-slate-200 bg-white text-xs font-semibold text-slate-700 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary cursor-pointer"
1506
+ >
1507
+ <option value="all">Semua Model</option>
1508
+ <option value="lightgbm">LightGBM</option>
1509
+ <option value="xgboost">XGBoost</option>
1510
+ <option value="prophet">Prophet</option>
1511
+ <option value="sarima">SARIMA</option>
1512
+ <option value="arima">ARIMA</option>
1513
+ </select>
1514
+ </div>
1515
+ </div>
1516
  </div>
1517
 
1518
+ {/* Table with filtered + paginated data */}
1519
+ {(() => {
1520
+ // Apply search + filters
1521
+ const allItems = activeModelsSummary?.commodity_models || []
1522
+ const filtered = allItems.filter((item: any) => {
1523
+ // Search filter
1524
+ const query = aiSearchQuery.toLowerCase().trim()
1525
+ if (query) {
1526
+ const nameMatch = (item.komoditas_nama || '').toLowerCase().includes(query)
1527
+ const modelMatch = (item.nama_model || '').toLowerCase().includes(query)
1528
+ if (!nameMatch && !modelMatch) return false
1529
+ }
1530
+ // Status filter
1531
+ if (aiFilterStatus === 'trained' && !item.nama_model) return false
1532
+ if (aiFilterStatus === 'untrained' && item.nama_model) return false
1533
+ // Model type filter
1534
+ if (aiFilterModel !== 'all') {
1535
+ if (!item.nama_model || !item.nama_model.toLowerCase().includes(aiFilterModel)) return false
1536
+ }
1537
+ return true
1538
+ })
1539
+
1540
+ const totalPages = Math.max(1, Math.ceil(filtered.length / aiRowsPerPage))
1541
+ const safePage = Math.min(aiCurrentPage, totalPages)
1542
+ const startIdx = (safePage - 1) * aiRowsPerPage
1543
+ const paginatedItems = filtered.slice(startIdx, startIdx + aiRowsPerPage)
1544
+
1545
+ return (
1546
+ <>
1547
+ <div className="overflow-x-auto">
1548
+ <table className="w-full text-left border-collapse">
1549
+ <thead>
1550
+ <tr className="border-b border-slate-200/60 bg-slate-50/30">
1551
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">Komoditas & Model</th>
1552
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">MAPE (%)</th>
1553
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">RMSE</th>
1554
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">MAE</th>
1555
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider">R² Score</th>
1556
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider text-center">Status Validasi</th>
1557
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider text-center">Status</th>
1558
+ <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-wider text-right">Aksi</th>
1559
+ </tr>
1560
+ </thead>
1561
+ <tbody className="divide-y divide-slate-100">
1562
+ {loadingSummary && !activeModelsSummary ? (
1563
+ <tr>
1564
+ <td colSpan={8} className="text-center py-16">
1565
+ <RefreshCw className="w-8 h-8 animate-spin text-slate-400 mx-auto mb-2" />
1566
+ <p className="text-xs text-slate-500 font-medium">Memuat summary model...</p>
1567
+ </td>
1568
+ </tr>
1569
+ ) : paginatedItems.length === 0 ? (
1570
+ <tr>
1571
+ <td colSpan={8} className="text-center py-16">
1572
+ <Search className="w-8 h-8 text-slate-300 mx-auto mb-3" />
1573
+ <p className="text-xs text-slate-500 font-medium">
1574
+ {aiSearchQuery || aiFilterStatus !== 'all' || aiFilterModel !== 'all'
1575
+ ? 'Tidak ada komoditas yang cocok dengan filter.'
1576
+ : 'Belum ada model machine learning yang dilatih.'}
1577
+ </p>
1578
+ {(aiSearchQuery || aiFilterStatus !== 'all' || aiFilterModel !== 'all') && (
1579
+ <button
1580
+ onClick={() => { setAiSearchQuery(''); setAiFilterStatus('all'); setAiFilterModel('all'); setAiCurrentPage(1); }}
1581
+ className="mt-2 text-[11px] font-bold text-primary hover:underline"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1582
  >
1583
+ Reset semua filter
1584
  </button>
1585
  )}
1586
+ </td>
1587
+ </tr>
1588
+ ) : (
1589
+ paginatedItems.map((item: any) => {
1590
+ const isRunning = forecastRunningIds[item.komoditas_id] || forecastStatus?.running_tasks?.[item.komoditas_id];
1591
+ return (
1592
+ <tr key={item.komoditas_id} className="hover:bg-slate-50/50 transition-colors group">
1593
+ <td className="px-6 py-4">
1594
+ <div className="flex items-center gap-3">
1595
+ <div className="w-10 h-10 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center shrink-0 border border-indigo-100">
1596
+ <Activity className="w-5 h-5 text-indigo-500" />
1597
+ </div>
1598
+ <div>
1599
+ <p className="text-xs font-black text-slate-900">{item.komoditas_nama}</p>
1600
+ <p className="text-[10px] font-bold text-primary mt-0.5 tracking-wide">
1601
+ {item.nama_model ? item.nama_model : 'Belum Latih Model'}
1602
+ {item.nama_model && <span className="text-slate-400 font-medium ml-1">v1.0</span>}
1603
+ </p>
1604
+ </div>
1605
+ </div>
1606
+ </td>
1607
+ <td className="px-6 py-4 text-xs font-semibold text-slate-700">
1608
+ {item.mape !== null ? (
1609
+ <span className={`font-mono font-bold ${item.mape < 10 ? 'text-emerald-600' : (item.mape < 20 ? 'text-amber-600' : 'text-rose-600')}`}>
1610
+ {Number(item.mape).toFixed(2)}%
1611
+ </span>
1612
+ ) : '-'}
1613
+ </td>
1614
+ <td className="px-6 py-4 text-xs font-mono text-slate-600">
1615
+ {item.rmse !== null ? Number(item.rmse).toLocaleString('id-ID', { maximumFractionDigits: 2 }) : '-'}
1616
+ </td>
1617
+ <td className="px-6 py-4 text-xs font-mono text-slate-600">
1618
+ {item.mae !== null ? Number(item.mae).toLocaleString('id-ID', { maximumFractionDigits: 2 }) : '-'}
1619
+ </td>
1620
+ <td className="px-6 py-4 text-xs font-mono text-slate-600">
1621
+ {item.r2_score !== null ? Number(item.r2_score).toFixed(4) : '-'}
1622
+ </td>
1623
+ <td className="px-6 py-4 text-center">
1624
+ {item.nama_model ? (
1625
+ <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-[10px] font-bold border border-emerald-100">
1626
+ Terverifikasi
1627
+ </span>
1628
+ ) : (
1629
+ <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded bg-slate-100 text-slate-500 text-[10px] font-bold border border-slate-200">
1630
+ Belum Latih
1631
+ </span>
1632
+ )}
1633
+ </td>
1634
+ <td className="px-6 py-4 text-center">
1635
+ {item.is_active ? (
1636
+ <span className="relative flex items-center justify-center h-2.5 w-2.5 mx-auto" title="Model Aktif">
1637
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
1638
+ <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
1639
+ </span>
1640
+ ) : (
1641
+ <span className="inline-block w-2.5 h-2.5 rounded-full bg-slate-300 mx-auto" title="Tidak Aktif"></span>
1642
+ )}
1643
+ </td>
1644
+ <td className="px-6 py-4 text-right">
1645
+ <div className="flex items-center justify-end gap-1.5">
1646
+ <button
1647
+ onClick={() => handleOpenDetailModal(item.komoditas_id)}
1648
+ disabled={!item.nama_model || loadingDetailId === item.komoditas_id}
1649
+ className="w-8 h-8 rounded-lg bg-slate-50 text-slate-500 hover:text-primary hover:bg-blue-50 flex items-center justify-center transition-all border border-slate-200 hover:border-blue-200 disabled:opacity-30 disabled:pointer-events-none"
1650
+ title="Detail Performa"
1651
+ >
1652
+ <Info className="w-3.5 h-3.5" />
1653
+ </button>
1654
+ <button
1655
+ onClick={() => {
1656
+ setSelectedConfigCommodityId(item.komoditas_id);
1657
+ setActiveTab('settings');
1658
+ }}
1659
+ className="w-8 h-8 rounded-lg bg-slate-50 text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 flex items-center justify-center transition-all border border-slate-200 hover:border-indigo-200"
1660
+ title="Konfigurasi ML"
1661
+ >
1662
+ <Sliders className="w-3.5 h-3.5" />
1663
+ </button>
1664
+ {isRunning ? (
1665
+ <button
1666
+ onClick={handleStopForecast}
1667
+ className="w-8 h-8 rounded-lg bg-rose-50 text-rose-600 flex items-center justify-center border border-rose-100 animate-pulse"
1668
+ title="Stop Training"
1669
+ >
1670
+ <Square className="w-3.5 h-3.5 fill-current" />
1671
+ </button>
1672
+ ) : (
1673
+ <button
1674
+ onClick={() => handleRunForecast(item.komoditas_id)}
1675
+ disabled={isForecastRunning}
1676
+ className="w-8 h-8 rounded-lg bg-slate-50 text-slate-500 hover:text-emerald-600 hover:bg-emerald-50 flex items-center justify-center transition-all border border-slate-200 hover:border-emerald-200 disabled:opacity-30"
1677
+ title="Retrain Model"
1678
+ >
1679
+ <Play className="w-3.5 h-3.5 fill-current" />
1680
+ </button>
1681
+ )}
1682
+ </div>
1683
+ </td>
1684
+ </tr>
1685
+ )
1686
+ })
1687
+ )}
1688
+ </tbody>
1689
+ </table>
1690
+ </div>
1691
+
1692
+ {/* Pagination Footer */}
1693
+ {filtered.length > 0 && (
1694
+ <div className="px-6 py-4 border-t border-slate-100 bg-slate-50/30 flex flex-col sm:flex-row items-center justify-between gap-3">
1695
+ <div className="text-[11px] text-slate-500 font-medium">
1696
+ Menampilkan <span className="font-bold text-slate-700">{startIdx + 1}</span>–<span className="font-bold text-slate-700">{Math.min(startIdx + aiRowsPerPage, filtered.length)}</span> dari <span className="font-bold text-slate-700">{filtered.length}</span> komoditas
1697
+ {(aiSearchQuery || aiFilterStatus !== 'all' || aiFilterModel !== 'all') && (
1698
+ <span className="text-slate-400 ml-1">(difilter dari {allItems.length} total)</span>
1699
+ )}
1700
+ </div>
1701
+ <div className="flex items-center gap-1.5">
1702
+ <button
1703
+ onClick={() => setAiCurrentPage(1)}
1704
+ disabled={safePage <= 1}
1705
+ className="px-2.5 py-1.5 rounded-lg bg-white border border-slate-200 text-slate-600 text-[10px] font-bold hover:bg-slate-100 disabled:opacity-30 disabled:pointer-events-none transition-all"
1706
+ >
1707
+ Awal
1708
+ </button>
1709
+ <button
1710
+ onClick={() => setAiCurrentPage(p => Math.max(1, p - 1))}
1711
+ disabled={safePage <= 1}
1712
+ className="p-1.5 rounded-lg bg-white border border-slate-200 text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:pointer-events-none transition-all"
1713
+ >
1714
+ <ChevronLeft className="w-3.5 h-3.5" />
1715
+ </button>
1716
+
1717
+ {/* Page number buttons */}
1718
+ {Array.from({ length: totalPages }, (_, i) => i + 1)
1719
+ .filter(p => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
1720
+ .reduce((acc: (number | string)[], p, i, arr) => {
1721
+ if (i > 0 && typeof arr[i-1] === 'number' && (p as number) - (arr[i-1] as number) > 1) {
1722
+ acc.push('...')
1723
+ }
1724
+ acc.push(p)
1725
+ return acc
1726
+ }, [])
1727
+ .map((p, i) => (
1728
+ typeof p === 'string' ? (
1729
+ <span key={`dots-${i}`} className="px-1 text-slate-400 text-[10px]">...</span>
1730
+ ) : (
1731
+ <button
1732
+ key={p}
1733
+ onClick={() => setAiCurrentPage(p)}
1734
+ className={`min-w-[28px] h-7 rounded-lg text-[10px] font-bold transition-all ${
1735
+ p === safePage
1736
+ ? 'bg-primary text-white shadow-sm shadow-primary/20 border border-primary'
1737
+ : 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-100'
1738
+ }`}
1739
+ >
1740
+ {p}
1741
+ </button>
1742
+ )
1743
+ ))
1744
+ }
1745
+
1746
+ <button
1747
+ onClick={() => setAiCurrentPage(p => Math.min(totalPages, p + 1))}
1748
+ disabled={safePage >= totalPages}
1749
+ className="p-1.5 rounded-lg bg-white border border-slate-200 text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:pointer-events-none transition-all"
1750
+ >
1751
+ <ChevronRight className="w-3.5 h-3.5" />
1752
+ </button>
1753
+ <button
1754
+ onClick={() => setAiCurrentPage(totalPages)}
1755
+ disabled={safePage >= totalPages}
1756
+ className="px-2.5 py-1.5 rounded-lg bg-white border border-slate-200 text-slate-600 text-[10px] font-bold hover:bg-slate-100 disabled:opacity-30 disabled:pointer-events-none transition-all"
1757
+ >
1758
+ Akhir
1759
+ </button>
1760
+ </div>
1761
+ </div>
1762
  )}
1763
+ </>
1764
+ )
1765
+ })()}
1766
  </div>
1767
  </div>
1768
  )}