anoderb commited on
Commit
c995585
·
1 Parent(s): c976040

style: redesign Daily Scrapper UI/UX to match Sikomo layout and color palette

Browse files
frontend/app/globals.css CHANGED
@@ -3,53 +3,103 @@
3
  @tailwind utilities;
4
 
5
  @layer utilities {
6
- /* Glassmorphism effects */
7
- .glass-panel {
8
- background: rgba(15, 23, 42, 0.65);
9
- backdrop-filter: blur(12px);
10
- -webkit-backdrop-filter: blur(12px);
11
- border: 1px solid rgba(255, 255, 255, 0.08);
12
- box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
13
  }
14
 
15
- .glass-input {
16
- background: rgba(255, 255, 255, 0.05);
17
- backdrop-filter: blur(4px);
18
- border: 1px solid rgba(255, 255, 255, 0.1);
 
 
 
 
 
 
 
 
19
  }
20
 
21
- .glass-input:focus {
22
- background: rgba(255, 255, 255, 0.08);
23
- border-color: rgba(45, 212, 191, 0.5);
24
- box-shadow: 0 0 15px rgba(45, 212, 191, 0.2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  }
26
  }
27
 
28
- /* Custom Scrollbar for Logs */
29
- ::-webkit-scrollbar {
30
  width: 6px;
31
  height: 6px;
32
  }
33
 
34
- ::-webkit-scrollbar-track {
35
  background: rgba(15, 23, 42, 0.8);
36
  }
37
 
38
- ::-webkit-scrollbar-thumb {
39
  background: rgba(255, 255, 255, 0.15);
40
  border-radius: 3px;
41
  }
42
 
43
- ::-webkit-scrollbar-thumb:hover {
44
- background: rgba(45, 212, 191, 0.4);
45
  }
46
 
47
- /* Animations */
48
- @keyframes glow {
49
- 0%, 100% { opacity: 0.4; transform: scale(1); }
50
- 50% { opacity: 0.6; transform: scale(1.05); }
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
- .animate-glow {
54
- animation: glow 8s ease-in-out infinite alternate;
 
 
55
  }
 
3
  @tailwind utilities;
4
 
5
  @layer utilities {
6
+ /* Sikomo-style card panel */
7
+ .card-panel {
8
+ background: #ffffff;
9
+ border-radius: 1rem;
10
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.05);
11
+ border: 1px solid rgba(226, 232, 240, 0.6);
 
12
  }
13
 
14
+ /* Clean form inputs */
15
+ .form-input {
16
+ appearance: none;
17
+ width: 100%;
18
+ padding: 0.5rem 0.75rem;
19
+ border: 1px solid #cbd5e1;
20
+ border-radius: 0.5rem;
21
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
22
+ font-size: 0.875rem;
23
+ color: #1e293b;
24
+ background: #ffffff;
25
+ transition: all 150ms ease;
26
  }
27
 
28
+ .form-input::placeholder {
29
+ color: #94a3b8;
30
+ }
31
+
32
+ .form-input:focus {
33
+ outline: none;
34
+ border-color: #1e3a8a;
35
+ box-shadow: 0 0 0 3px rgba(30, 58, 138, 0.1);
36
+ }
37
+
38
+ /* Select dropdown styling */
39
+ .form-select {
40
+ appearance: none;
41
+ width: 100%;
42
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
43
+ border: 1px solid #cbd5e1;
44
+ border-radius: 0.5rem;
45
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
46
+ font-size: 0.875rem;
47
+ color: #1e293b;
48
+ background-color: #ffffff;
49
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
50
+ background-position: right 0.5rem center;
51
+ background-repeat: no-repeat;
52
+ background-size: 1.5em 1.5em;
53
+ transition: all 150ms ease;
54
+ }
55
+
56
+ .form-select:focus {
57
+ outline: none;
58
+ border-color: #1e3a8a;
59
+ box-shadow: 0 0 0 3px rgba(30, 58, 138, 0.1);
60
  }
61
  }
62
 
63
+ /* Terminal Scrollbar (dark context) */
64
+ .terminal-scrollbar::-webkit-scrollbar {
65
  width: 6px;
66
  height: 6px;
67
  }
68
 
69
+ .terminal-scrollbar::-webkit-scrollbar-track {
70
  background: rgba(15, 23, 42, 0.8);
71
  }
72
 
73
+ .terminal-scrollbar::-webkit-scrollbar-thumb {
74
  background: rgba(255, 255, 255, 0.15);
75
  border-radius: 3px;
76
  }
77
 
78
+ .terminal-scrollbar::-webkit-scrollbar-thumb:hover {
79
+ background: rgba(255, 255, 255, 0.3);
80
  }
81
 
82
+ /* General Scrollbar (light context) */
83
+ ::-webkit-scrollbar {
84
+ width: 6px;
85
+ height: 6px;
86
+ }
87
+
88
+ ::-webkit-scrollbar-track {
89
+ background: transparent;
90
+ }
91
+
92
+ ::-webkit-scrollbar-thumb {
93
+ background: rgba(148, 163, 184, 0.4);
94
+ border-radius: 3px;
95
+ }
96
+
97
+ ::-webkit-scrollbar-thumb:hover {
98
+ background: rgba(148, 163, 184, 0.6);
99
  }
100
 
101
+ /* Smooth transitions */
102
+ * {
103
+ transition-property: color, background-color, border-color, box-shadow;
104
+ transition-timing-function: ease;
105
  }
frontend/app/layout.tsx CHANGED
@@ -5,8 +5,8 @@ import { Inter } from 'next/font/google'
5
  const inter = Inter({ subsets: ['latin'] })
6
 
7
  export const metadata: Metadata = {
8
- title: 'Sikomo Daily Scraper Portal',
9
- description: 'Premium Automated Commodity Price Scraper & Control Panel',
10
  }
11
 
12
  export default function RootLayout({
@@ -16,7 +16,7 @@ export default function RootLayout({
16
  }) {
17
  return (
18
  <html lang="id" className="scroll-smooth">
19
- <body className={`${inter.className} bg-slate-900 text-slate-100 min-h-screen antialiased selection:bg-teal-500 selection:text-white`}>
20
  {children}
21
  </body>
22
  </html>
 
5
  const inter = Inter({ subsets: ['latin'] })
6
 
7
  export const metadata: Metadata = {
8
+ title: 'Daily Scraper - SIKOMO',
9
+ description: 'Panel kontrol scraping harga komoditas otomatis - Sistem Informasi Komoditas Kabupaten Batang',
10
  }
11
 
12
  export default function RootLayout({
 
16
  }) {
17
  return (
18
  <html lang="id" className="scroll-smooth">
19
+ <body className={`${inter.className} bg-[#f8fafc] text-slate-800 min-h-screen antialiased selection:bg-primary selection:text-white`}>
20
  {children}
21
  </body>
22
  </html>
frontend/app/page.tsx CHANGED
@@ -20,7 +20,12 @@ import {
20
  AlertTriangle,
21
  Info,
22
  X,
23
- Layers
 
 
 
 
 
24
  } from 'lucide-react'
25
 
26
  // Auto-detect base path/URL if running exported on FastAPI or standalone Next.js
@@ -247,58 +252,79 @@ export default function Dashboard() {
247
  // --- RENDER LOGIN GATE ---
248
  if (!isAuthenticated) {
249
  return (
250
- <div className="min-h-screen relative overflow-hidden flex items-center justify-center p-4">
251
- {/* Background glowing blobs */}
252
- <div className="absolute w-[500px] h-[500px] bg-teal-500/20 rounded-full blur-3xl -top-32 -left-32 animate-glow pointer-events-none" />
253
- <div className="absolute w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl -bottom-48 -right-48 animate-glow pointer-events-none" style={{ animationDelay: '3s' }} />
254
-
255
- <div className="w-full max-w-md glass-panel rounded-2xl p-8 relative z-10 border border-white/10 shadow-2xl">
256
- <div className="text-center mb-8">
257
- <div className="w-16 h-16 bg-gradient-to-tr from-teal-500 to-indigo-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-teal-500/25">
258
- <Lock className="w-8 h-8 text-white" />
259
- </div>
260
- <h1 className="text-3xl font-bold bg-gradient-to-r from-white via-slate-100 to-slate-400 bg-clip-text text-transparent">
261
- Sikomo Scraper Portal
262
- </h1>
263
- <p className="text-slate-400 text-sm mt-2">
264
- Masukkan kata sandi untuk mengakses panel kontrol scraping harga otomatis.
265
- </p>
266
- </div>
267
-
268
- <form onSubmit={handleLogin} className="space-y-6">
269
  <div>
270
- <label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
271
- Kata Sandi
272
- </label>
273
- <input
274
- type="password"
275
- value={passwordInput}
276
- onChange={(e) => setPasswordInput(e.target.value)}
277
- placeholder="••••••••••••"
278
- className={`w-full glass-input rounded-xl px-4 py-3.5 text-slate-100 placeholder:text-slate-500 focus:outline-none transition-all ${
279
- authError ? 'border-rose-500/50 focus:border-rose-500 focus:box-shadow-[0_0_15px_rgba(244,63,94,0.2)]' : ''
280
- }`}
281
- autoFocus
282
- />
283
- {authError && (
284
- <p className="text-xs text-rose-400 mt-2 flex items-center gap-1">
285
- <AlertTriangle className="w-3.5 h-3.5" /> Kata sandi tidak cocok.
286
- </p>
287
- )}
288
  </div>
289
 
290
- <button
291
- type="submit"
292
- className="w-full bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-semibold py-3.5 px-4 rounded-xl shadow-lg shadow-indigo-500/20 active:scale-[0.98] transition-all duration-200"
293
- >
294
- Masuk ke Portal
295
- </button>
296
- </form>
 
297
 
298
- <div className="mt-6 text-center border-t border-white/5 pt-4">
299
- <span className="text-[11px] text-slate-500">
300
- Sistem Informasi Komoditas & Pasar Kabupaten Batang
301
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  </div>
303
  </div>
304
  </div>
@@ -307,137 +333,150 @@ export default function Dashboard() {
307
 
308
  // --- RENDER DASHBOARD ---
309
  return (
310
- <div className="min-h-screen relative overflow-hidden pb-16">
311
- {/* Subtle Background Glows */}
312
- <div className="absolute top-0 right-1/4 w-96 h-96 bg-teal-500/10 rounded-full blur-3xl pointer-events-none" />
313
- <div className="absolute bottom-10 left-10 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none" />
314
-
315
- {/* Top Navbar */}
316
- <header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-30 px-6 py-4">
317
- <div className="max-w-7xl mx-auto flex items-center justify-between">
318
- <div className="flex items-center gap-3">
319
- <div className="w-10 h-10 bg-gradient-to-tr from-teal-500 to-indigo-500 rounded-xl flex items-center justify-center shadow-md">
320
- <Activity className="w-5 h-5 text-white" />
321
- </div>
322
- <div>
323
- <h1 className="font-bold text-lg leading-tight bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">
324
- Sikomo Daily Scraper
325
- </h1>
326
- <div className="flex items-center gap-2 mt-0.5">
327
- <span className="flex h-2 w-2 relative">
328
- <span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${status?.is_running ? 'bg-teal-400' : 'bg-rose-400'}`} />
329
- <span className={`relative inline-flex rounded-full h-2 w-2 ${status?.is_running ? 'bg-teal-500' : 'bg-rose-500'}`} />
330
- </span>
331
- <span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">
332
- Engine: {status?.is_running ? 'Active' : 'Inactive'}
333
- </span>
334
- </div>
335
  </div>
 
336
  </div>
337
 
338
- <div className="flex items-center gap-3">
339
- {currentTime && (
340
- <div className="hidden sm:flex items-center gap-2 bg-slate-950/60 px-3 py-1.5 rounded-xl border border-white/5 font-mono text-xs text-teal-400">
341
- <span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
342
- <span>{currentTime} WIB</span>
343
- </div>
344
- )}
345
 
346
- <button
347
- onClick={handleLogout}
348
- className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium border border-slate-700 transition"
349
- >
350
- <LogOut className="w-3.5 h-3.5" />
351
- Keluar
352
- </button>
 
 
 
353
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  </div>
355
  </header>
356
 
357
  {/* Main Container */}
358
- <main className="max-w-7xl mx-auto px-6 mt-8 grid grid-cols-1 lg:grid-cols-3 gap-6">
359
 
360
  {/* LEFT & CENTER COLUMNS: Status & Controls */}
361
  <div className="lg:col-span-2 space-y-6">
362
 
 
 
 
 
 
 
363
  {/* SECTION 1: Status & Quick Actions Bento */}
364
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
365
 
366
  {/* Status Overview Card */}
367
- <div className="glass-panel rounded-2xl p-6 border border-white/5 relative overflow-hidden flex flex-col justify-between">
368
  <div>
369
  <div className="flex items-center justify-between mb-4">
370
- <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
371
- <Activity className="w-4 h-4 text-teal-400" /> Status Scheduler
372
  </h2>
373
  <span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${
374
- status?.is_running ? 'bg-teal-500/10 text-teal-400 border border-teal-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
 
 
375
  }`}>
376
  {status?.is_running ? 'RUNNING' : 'STOPPED'}
377
  </span>
378
  </div>
379
 
380
  <div className="space-y-3 mt-4">
381
- <div className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
382
  <span className="text-xs text-slate-500 block">Jadwal Aktif</span>
383
- <span className="text-lg font-bold text-slate-200 mt-0.5">
384
  {status?.active_schedules_count || 0} <span className="text-xs font-normal text-slate-400">tugas rutin</span>
385
  </span>
386
  </div>
387
 
388
- <div className="bg-slate-900/50 rounded-xl p-3 border border-white/5">
389
  <span className="text-xs text-slate-500 block">Eksekusi Rutin Berikutnya</span>
390
- <span className="text-sm font-bold text-teal-400 block truncate mt-0.5">
391
  {status?.next_run ? new Date(status.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : 'Tidak ada jadwal terdaftar'}
392
  </span>
393
  </div>
394
  </div>
395
  </div>
396
 
 
397
  <button
398
  onClick={handleToggleScheduler}
399
- className={`w-full mt-4 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center gap-2 border transition ${
400
  status?.is_running
401
- ? 'bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border-rose-500/20'
402
- : 'bg-teal-500/10 hover:bg-teal-500/20 text-teal-400 border-teal-500/20'
403
  }`}
404
  >
405
  {status?.is_running ? <Square className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
406
  {status?.is_running ? 'Matikan Scheduler Otomatis' : 'Nyalakan Scheduler Otomatis'}
407
  </button>
 
 
 
408
  </div>
409
 
410
  {/* Manual Scrape Card */}
411
- <div className="glass-panel rounded-2xl p-6 border border-white/5 flex flex-col justify-between relative overflow-hidden">
412
- <div className="absolute top-0 right-0 p-8 opacity-5 text-teal-500 pointer-events-none">
413
- <Layers className="w-32 h-32" />
414
- </div>
415
-
416
  <div>
417
- <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2 mb-2">
418
- <RefreshCw className="w-4 h-4 text-indigo-400" /> Aksi Cepat Manual
419
  </h2>
420
- <p className="text-xs text-slate-400 leading-relaxed mb-6">
421
  Picu mesin scraper sekarang juga untuk mengambil harga terbaru dari server website sumber tanpa menunggu jadwal.
422
  </p>
423
 
424
  {/* Last Run Snippet */}
425
  {status?.last_result?.stats && (
426
- <div className="bg-slate-900/60 rounded-xl p-3 border border-white/5 mb-4 text-xs">
427
- <div className="flex items-center justify-between text-slate-400 mb-1.5 pb-1.5 border-b border-white/5 font-medium">
428
  <span>Statistik Scrape Terakhir</span>
429
  <button
430
  onClick={() => { setSelectedStats(status.last_result.stats); setShowStatsModal(true); }}
431
- className="text-teal-400 hover:underline flex items-center gap-1"
432
  >
433
  Detail Breakdown <Info className="w-3 h-3" />
434
  </button>
435
  </div>
436
  <div className="grid grid-cols-4 gap-1 text-center pt-0.5">
437
- <div><span className="block text-[10px] text-slate-500">Insert</span><span className="font-bold text-teal-400">{status.last_result.stats.total_insert}</span></div>
438
- <div><span className="block text-[10px] text-slate-500">Update</span><span className="font-bold text-indigo-400">{status.last_result.stats.total_update}</span></div>
439
- <div><span className="block text-[10px] text-slate-500">Skip</span><span className="font-bold text-slate-400">{status.last_result.stats.total_skip}</span></div>
440
- <div><span className="block text-[10px] text-slate-500">Harga 0</span><span className="font-bold text-amber-400">{status.last_result.stats.total_harga_0}</span></div>
441
  </div>
442
  </div>
443
  )}
@@ -447,7 +486,7 @@ export default function Dashboard() {
447
  <button
448
  onClick={handleManualScrape}
449
  disabled={loadingScrape}
450
- className="w-full bg-gradient-to-r from-teal-500 to-indigo-500 hover:from-teal-400 hover:to-indigo-400 text-white font-semibold py-3 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-teal-500/10 disabled:opacity-50 transition active:scale-[0.98]"
451
  >
452
  {loadingScrape ? (
453
  <>
@@ -467,12 +506,12 @@ export default function Dashboard() {
467
  type="date"
468
  value={customDate}
469
  onChange={(e) => setCustomDate(e.target.value)}
470
- className="flex-1 glass-input rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none bg-slate-900/50 border border-white/10 h-[42px]"
471
  />
472
  <button
473
  onClick={handleCustomScrape}
474
  disabled={loadingScrape || !customDate}
475
- className="bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-400 font-semibold px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg disabled:opacity-50 transition active:scale-[0.98] border border-indigo-500/30 text-xs h-[42px]"
476
  >
477
  <Calendar className="w-3.5 h-3.5" /> Scrape Tanggal
478
  </button>
@@ -481,48 +520,51 @@ export default function Dashboard() {
481
  {loadingScrape && (
482
  <button
483
  onClick={handleStopScrape}
484
- className="w-full bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 font-medium py-2 px-4 rounded-xl flex items-center justify-center gap-2 border border-rose-500/30 transition animate-pulse"
485
  >
486
  <Square className="w-3.5 h-3.5 fill-current" />
487
  Hentikan Proses
488
  </button>
489
  )}
490
  </div>
 
 
 
491
  </div>
492
 
493
  </div>
494
 
495
  {/* SECTION 2: Multiple Schedules Manager */}
496
- <div className="glass-panel rounded-2xl p-6 border border-white/5">
497
  <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
498
  <div>
499
- <h2 className="text-base font-bold text-slate-100 flex items-center gap-2">
500
- <Calendar className="w-5 h-5 text-teal-400" />
501
- Manajemen Jadwal Rutin (Multiple Schedules)
502
  </h2>
503
- <p className="text-xs text-slate-400 mt-1">
504
  Atur waktu pengambilan harga otomatis. Anda dapat menambahkan lebih dari satu jadwal per hari.
505
  </p>
506
  </div>
507
 
508
  {/* Quick Presets */}
509
- <div className="flex flex-wrap gap-1.5 bg-slate-900/60 p-1.5 rounded-xl border border-white/5 self-start sm:self-auto">
510
- <span className="text-[10px] font-semibold text-slate-500 px-2 self-center">Preset:</span>
511
  <button
512
  onClick={() => addPresetCron('08', '00', 'Pagi Hari (08:00)')}
513
- className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition"
514
  >
515
  08:00
516
  </button>
517
  <button
518
  onClick={() => addPresetCron('12', '00', 'Siang Hari (12:00)')}
519
- className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition"
520
  >
521
  12:00
522
  </button>
523
  <button
524
  onClick={() => addPresetCron('17', '00', 'Sore Hari (17:00)')}
525
- className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] rounded-lg transition"
526
  >
527
  17:00
528
  </button>
@@ -530,49 +572,49 @@ export default function Dashboard() {
530
  </div>
531
 
532
  {/* Add Schedule Form */}
533
- <form onSubmit={handleAddSchedule} className="grid grid-cols-1 md:grid-cols-12 gap-3 mb-6 bg-slate-900/40 p-4 rounded-xl border border-white/5">
534
  <div className="md:col-span-3">
535
- <label className="block text-[11px] font-medium text-slate-400 mb-1">Jam (WIB)</label>
536
  <select
537
  value={selectedHour}
538
  onChange={(e) => setSelectedHour(e.target.value)}
539
- className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none"
540
  >
541
  {Array.from({ length: 24 }).map((_, i) => (
542
- <option key={i} value={i.toString().padStart(2, '0')} className="bg-slate-900">
543
  {i.toString().padStart(2, '0')}
544
  </option>
545
  ))}
546
  </select>
547
  </div>
548
  <div className="md:col-span-3">
549
- <label className="block text-[11px] font-medium text-slate-400 mb-1">Menit</label>
550
  <select
551
  value={selectedMinute}
552
  onChange={(e) => setSelectedMinute(e.target.value)}
553
- className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none"
554
  >
555
  {Array.from({ length: 60 }).map((_, i) => (
556
- <option key={i} value={i.toString().padStart(2, '0')} className="bg-slate-900">
557
  {i.toString().padStart(2, '0')}
558
  </option>
559
  ))}
560
  </select>
561
  </div>
562
  <div className="md:col-span-4">
563
- <label className="block text-[11px] font-medium text-slate-400 mb-1">Label Jadwal</label>
564
  <input
565
  type="text"
566
  value={newLabel}
567
  onChange={(e) => setNewLabel(e.target.value)}
568
  placeholder="Contoh: Scraping Pagi"
569
- className="w-full glass-input rounded-lg px-3 py-2 text-xs text-slate-200 placeholder:text-slate-600 focus:outline-none"
570
  />
571
  </div>
572
  <div className="md:col-span-2 flex items-end">
573
  <button
574
  type="submit"
575
- className="w-full bg-teal-500 hover:bg-teal-400 text-slate-900 font-bold py-2 px-3 rounded-lg text-xs flex items-center justify-center gap-1 shadow transition"
576
  >
577
  <Plus className="w-3.5 h-3.5 stroke-[3]" /> Tambah
578
  </button>
@@ -583,28 +625,28 @@ export default function Dashboard() {
583
  <div className="overflow-x-auto">
584
  <table className="w-full text-left border-collapse">
585
  <thead>
586
- <tr className="border-b border-slate-800 text-[11px] text-slate-500 font-semibold uppercase">
587
  <th className="pb-3 px-3">Label Jadwal</th>
588
  <th className="pb-3 px-3">Waktu (WIB)</th>
589
  <th className="pb-3 px-3">Estimasi Run Berikutnya</th>
590
  <th className="pb-3 px-3 text-right">Aksi</th>
591
  </tr>
592
  </thead>
593
- <tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
594
  {schedules.length === 0 ? (
595
  <tr>
596
- <td colSpan={4} className="text-center py-8 text-slate-500 italic">
597
  Belum ada jadwal yang didaftarkan. Gunakan form di atas untuk menambah jadwal.
598
  </td>
599
  </tr>
600
  ) : (
601
  schedules.map((item) => (
602
- <tr key={item.id} className="hover:bg-slate-800/30 transition">
603
- <td className="py-3 px-3 font-medium text-slate-200">
604
  {item.label}
605
  </td>
606
- <td className="py-3 px-3">
607
- <span className="font-mono text-teal-400 font-bold bg-teal-500/10 py-1 px-2 rounded-lg border border-teal-500/20">
608
  {(() => {
609
  const parts = item.cron_expression.split(' ');
610
  if (parts.length >= 2 && !isNaN(parseInt(parts[0])) && !isNaN(parseInt(parts[1]))) {
@@ -614,13 +656,13 @@ export default function Dashboard() {
614
  })()}
615
  </span>
616
  </td>
617
- <td className="py-3 px-3 text-slate-400">
618
  {item.next_run ? new Date(item.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : '-'}
619
  </td>
620
- <td className="py-3 px-3 text-right">
621
  <button
622
  onClick={() => handleRemoveSchedule(item.id)}
623
- className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition"
624
  title="Hapus Jadwal"
625
  >
626
  <Trash2 className="w-4 h-4" />
@@ -634,10 +676,10 @@ export default function Dashboard() {
634
  </div>
635
 
636
  {/* Hint */}
637
- <div className="mt-4 pt-4 border-t border-white/5 flex items-start gap-2 text-[11px] text-slate-500">
638
- <Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-slate-400" />
639
  <span>
640
- Format standar Cron: <code>(Menit) (Jam) (Hari) (Bulan) (Hari/Minggu)</code>. Waktu server saat ini mengikuti zona waktu sistem lokal.
641
  </span>
642
  </div>
643
  </div>
@@ -645,26 +687,26 @@ export default function Dashboard() {
645
  </div>
646
 
647
  {/* RIGHT COLUMN: Terminal Logs */}
648
- <div className="glass-panel rounded-2xl p-6 border border-white/5 flex flex-col h-[500px] lg:h-[700px]">
649
 
650
  {/* Logs Header */}
651
- <div className="flex items-center justify-between pb-4 mb-4 border-b border-white/5">
652
- <h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
653
- <Terminal className="w-4 h-4 text-indigo-400" />
654
  Log Eksekusi Real-time
655
  </h2>
656
 
657
- {/* Controls */}
658
  <div className="flex items-center gap-1.5">
659
- <div className="flex bg-slate-900/80 rounded-lg p-0.5 border border-white/5">
660
  {['ALL', 'INFO', 'WARNING', 'ERROR'].map((lvl) => (
661
  <button
662
  key={lvl}
663
  onClick={() => setLogLevelFilter(lvl)}
664
- className={`px-2 py-1 rounded-md text-[10px] font-bold transition ${
665
  logLevelFilter === lvl
666
- ? 'bg-slate-700 text-white'
667
- : 'text-slate-500 hover:text-slate-300'
668
  }`}
669
  >
670
  {lvl}
@@ -674,10 +716,10 @@ export default function Dashboard() {
674
  </div>
675
  </div>
676
 
677
- {/* Terminal Box */}
678
- <div ref={logsContainerRef} className="flex-1 bg-slate-950 rounded-xl p-4 font-mono text-xs overflow-y-auto border border-black/50 shadow-inner flex flex-col space-y-2">
679
  {logs.length === 0 ? (
680
- <div className="m-auto text-slate-600 text-center italic">
681
  [ Log buffer kosong / Menunggu aktivitas baru... ]
682
  </div>
683
  ) : (
@@ -690,7 +732,7 @@ export default function Dashboard() {
690
  const timeStr = new Date(log.timestamp).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
691
 
692
  return (
693
- <div key={idx} className="leading-relaxed break-words hover:bg-white/[0.02] px-1 rounded transition-colors">
694
  <span className="text-slate-600 select-none">[{timeStr}]</span>{' '}
695
  <span className={`font-semibold ${levelColor} select-none`}>[{log.level}]</span>{' '}
696
  <span className="text-slate-500 select-none">({log.source})</span>{' '}
@@ -702,10 +744,10 @@ export default function Dashboard() {
702
  <div ref={logsEndRef} />
703
  </div>
704
 
705
- {/* Logs Footer status */}
706
- <div className="mt-3 flex items-center justify-between text-[11px] text-slate-500 px-1">
707
- <span className="flex items-center gap-1">
708
- <span className="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse" /> Live polling aktif
709
  </span>
710
  <span>Total buffer: {logs.length} baris</span>
711
  </div>
@@ -715,40 +757,40 @@ export default function Dashboard() {
715
 
716
  {/* --- STATS BREAKDOWN MODAL --- */}
717
  {showStatsModal && selectedStats && (
718
- <div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
719
- <div className="glass-panel w-full max-w-2xl rounded-2xl p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
720
 
721
  <button
722
  onClick={() => setShowStatsModal(false)}
723
- className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white rounded-lg transition"
724
  >
725
  <X className="w-5 h-5" />
726
  </button>
727
 
728
- <h3 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
729
- <CheckCircle className="w-5 h-5 text-teal-400" /> Laporan Eksekusi Scraping
730
  </h3>
731
- <p className="text-xs text-slate-400 mb-6 pb-4 border-b border-white/5">
732
  Breakdown lengkap hasil sinkronisasi harga komoditas dan pasar hari ini.
733
  </p>
734
 
735
  {/* Grid Summaries */}
736
  <div className="grid grid-cols-4 gap-4 mb-6">
737
- <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
738
- <span className="text-[11px] text-slate-500 block">Insert Baru</span>
739
- <span className="text-xl font-bold text-teal-400">{selectedStats.total_insert}</span>
740
  </div>
741
- <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
742
- <span className="text-[11px] text-slate-500 block">Diperbarui</span>
743
- <span className="text-xl font-bold text-indigo-400">{selectedStats.total_update}</span>
744
  </div>
745
- <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
746
- <span className="text-[11px] text-slate-500 block">Data Lewat / Skip</span>
747
- <span className="text-xl font-bold text-slate-400">{selectedStats.total_skip}</span>
748
  </div>
749
- <div className="bg-slate-900/60 p-3 rounded-xl border border-white/5 text-center">
750
- <span className="text-[11px] text-slate-500 block">Harga Rp 0</span>
751
- <span className="text-xl font-bold text-amber-400">{selectedStats.total_harga_0}</span>
752
  </div>
753
  </div>
754
 
@@ -756,53 +798,53 @@ export default function Dashboard() {
756
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
757
 
758
  {/* Pasar Sukses */}
759
- <div className="bg-slate-900/40 p-4 rounded-xl border border-white/5">
760
- <span className="font-bold text-slate-300 block mb-2 text-[11px] uppercase tracking-wider text-teal-400 flex items-center gap-1.5">
761
  <Check className="w-3.5 h-3.5" /> Pasar Tersinkronisasi ({selectedStats.pasar_berhasil?.length || 0})
762
  </span>
763
  {selectedStats.pasar_berhasil?.length > 0 ? (
764
- <ul className="space-y-1 text-slate-300">
765
  {selectedStats.pasar_berhasil.map((psr: string, i: number) => (
766
  <li key={i} className="flex items-center gap-1.5">
767
- <span className="w-1 h-1 rounded-full bg-teal-400" /> {psr}
768
  </li>
769
  ))}
770
  </ul>
771
  ) : (
772
- <span className="text-slate-500 italic">- Tidak ada -</span>
773
  )}
774
  </div>
775
 
776
  {/* Pasar Harga 0 */}
777
- <div className="bg-slate-900/40 p-4 rounded-xl border border-white/5">
778
- <span className="font-bold text-slate-300 block mb-2 text-[11px] uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
779
  <AlertTriangle className="w-3.5 h-3.5" /> Pasar Harga Kosong/Nol ({selectedStats.pasar_harga_0?.length || 0})
780
  </span>
781
  {selectedStats.pasar_harga_0?.length > 0 ? (
782
- <ul className="space-y-1 text-slate-400">
783
  {selectedStats.pasar_harga_0.map((psr: string, i: number) => (
784
  <li key={i} className="flex items-center gap-1.5">
785
- <span className="w-1 h-1 rounded-full bg-amber-400" /> {psr}
786
  </li>
787
  ))}
788
  </ul>
789
  ) : (
790
- <span className="text-slate-500 italic">- Tidak ada -</span>
791
  )}
792
  </div>
793
 
794
  </div>
795
 
796
  {/* Bottom info */}
797
- <div className="mt-6 pt-4 border-t border-white/5 flex items-center justify-between text-slate-400 text-[11px]">
798
- <span>Total Komoditas Diperbarui: <strong>{selectedStats.komoditas_berhasil_count || 0} item</strong></span>
799
- <span>Komoditas Harga Nol: <strong>{selectedStats.komoditas_harga_0_count || 0} item</strong></span>
800
  </div>
801
 
802
  <div className="mt-6 text-right">
803
  <button
804
  onClick={() => setShowStatsModal(false)}
805
- className="bg-slate-800 hover:bg-slate-700 text-slate-200 font-medium py-2 px-4 rounded-xl transition"
806
  >
807
  Tutup Jendela
808
  </button>
 
20
  AlertTriangle,
21
  Info,
22
  X,
23
+ Layers,
24
+ Clock,
25
+ TrendingUp,
26
+ BarChart3,
27
+ Settings,
28
+ Zap
29
  } from 'lucide-react'
30
 
31
  // Auto-detect base path/URL if running exported on FastAPI or standalone Next.js
 
252
  // --- RENDER LOGIN GATE ---
253
  if (!isAuthenticated) {
254
  return (
255
+ <div className="min-h-screen flex">
256
+ {/* Left: Login Form */}
257
+ <div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
258
+ <div className="mx-auto w-full max-w-sm lg:w-96">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  <div>
260
+ <div className="flex items-center gap-2 mb-8">
261
+ <div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-white shadow-sm">
262
+ <TrendingUp className="w-5 h-5" />
263
+ </div>
264
+ <span className="font-bold text-2xl text-primary tracking-tight">SIKOMO<span className="text-primary">.</span></span>
265
+ </div>
266
+ <h2 className="mt-6 text-3xl font-extrabold text-slate-900">
267
+ Masuk ke Scraper Portal
268
+ </h2>
269
+ <p className="mt-2 text-sm text-slate-600">
270
+ Sistem Informasi Komoditas Kabupaten Batang — Panel Kontrol Scraping Otomatis
271
+ </p>
 
 
 
 
 
 
272
  </div>
273
 
274
+ <div className="mt-8">
275
+ <form onSubmit={handleLogin} className="space-y-6">
276
+ {authError && (
277
+ <div className="bg-red-50 border border-red-200 text-red-600 rounded-lg p-4 text-sm flex items-center gap-2">
278
+ <AlertTriangle className="w-4 h-4 flex-shrink-0" />
279
+ Kata sandi tidak cocok. Silakan coba lagi.
280
+ </div>
281
+ )}
282
 
283
+ <div>
284
+ <label htmlFor="password" className="block text-sm font-medium text-slate-700">
285
+ Kata Sandi
286
+ </label>
287
+ <div className="mt-1">
288
+ <input
289
+ id="password"
290
+ type="password"
291
+ value={passwordInput}
292
+ onChange={(e) => setPasswordInput(e.target.value)}
293
+ placeholder="Masukkan kata sandi"
294
+ className="form-input py-2.5"
295
+ autoFocus
296
+ />
297
+ </div>
298
+ </div>
299
+
300
+ <button
301
+ type="submit"
302
+ className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition-colors duration-200"
303
+ >
304
+ Masuk
305
+ </button>
306
+ </form>
307
+ </div>
308
+ </div>
309
+ </div>
310
+
311
+ {/* Right: Decorative Panel */}
312
+ <div className="hidden lg:block relative w-0 flex-1">
313
+ <div className="absolute inset-0 h-full w-full bg-dark overflow-hidden">
314
+ <div className="absolute inset-0 bg-primary opacity-90 mix-blend-multiply"></div>
315
+ <div className="absolute inset-0 flex items-center justify-center p-12">
316
+ <div className="max-w-xl text-center">
317
+ <div className="w-20 h-20 bg-white/10 backdrop-blur rounded-2xl flex items-center justify-center mx-auto mb-8">
318
+ <Activity className="w-10 h-10 text-white" />
319
+ </div>
320
+ <h1 className="text-4xl font-bold text-white mb-6">
321
+ Automated Price Scraping
322
+ </h1>
323
+ <p className="text-lg text-blue-100 leading-relaxed">
324
+ Pantau dan kelola proses pengambilan data harga komoditas secara otomatis dari berbagai sumber pasar di Kabupaten Batang.
325
+ </p>
326
+ </div>
327
+ </div>
328
  </div>
329
  </div>
330
  </div>
 
333
 
334
  // --- RENDER DASHBOARD ---
335
  return (
336
+ <div className="min-h-screen pb-16">
337
+
338
+ {/* Top Navbar Sikomo Style */}
339
+ <header className="h-16 bg-white/80 backdrop-blur-xl border-b border-slate-200/60 flex items-center justify-between px-6 z-30 sticky top-0 transition-all">
340
+ <div className="flex items-center gap-4">
341
+ {/* Brand */}
342
+ <div className="flex items-center gap-2.5">
343
+ <div className="w-8 h-8 bg-gradient-to-br from-primary to-indigo-600 rounded flex items-center justify-center text-white shadow-lg shadow-primary/20">
344
+ <TrendingUp className="w-4 h-4" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  </div>
346
+ <span className="text-slate-900 font-black text-lg tracking-tight">SIKOMO<span className="text-primary">.</span></span>
347
  </div>
348
 
349
+ <div className="hidden sm:flex items-center gap-2 ml-2">
350
+ <div className="h-6 w-1 bg-primary rounded-full"></div>
351
+ <h1 className="text-base font-bold text-slate-800 tracking-tight">Daily Scraper</h1>
352
+ </div>
353
+ </div>
 
 
354
 
355
+ <div className="flex items-center gap-4">
356
+ {/* Engine Status Indicator */}
357
+ <div className="hidden md:flex items-center gap-2">
358
+ <span className="flex h-2 w-2 relative">
359
+ <span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${status?.is_running ? 'bg-emerald-400' : 'bg-rose-400'}`} />
360
+ <span className={`relative inline-flex rounded-full h-2 w-2 ${status?.is_running ? 'bg-emerald-500' : 'bg-rose-500'}`} />
361
+ </span>
362
+ <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
363
+ Engine {status?.is_running ? 'Active' : 'Inactive'}
364
+ </span>
365
  </div>
366
+
367
+ {/* Date / Time */}
368
+ {currentTime && (
369
+ <div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-100">
370
+ <Clock className="w-3.5 h-3.5 text-slate-400" />
371
+ <span className="text-xs font-semibold text-slate-600 font-mono">{currentTime} WIB</span>
372
+ </div>
373
+ )}
374
+
375
+ <div className="w-px h-8 bg-slate-200"></div>
376
+
377
+ {/* Logout */}
378
+ <button
379
+ onClick={handleLogout}
380
+ className="flex items-center gap-2 px-3.5 py-2 rounded-lg text-slate-600 hover:bg-red-50 hover:text-red-600 text-xs font-semibold border border-transparent hover:border-red-200 transition-all duration-200"
381
+ >
382
+ <LogOut className="w-3.5 h-3.5" />
383
+ Keluar
384
+ </button>
385
  </div>
386
  </header>
387
 
388
  {/* Main Container */}
389
+ <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8 grid grid-cols-1 lg:grid-cols-3 gap-6">
390
 
391
  {/* LEFT & CENTER COLUMNS: Status & Controls */}
392
  <div className="lg:col-span-2 space-y-6">
393
 
394
+ {/* Page Title */}
395
+ <div className="mb-2">
396
+ <h1 className="text-2xl font-black text-slate-900 tracking-tight">Scraper Dashboard</h1>
397
+ <p className="text-sm text-slate-500 mt-1">Kelola dan pantau proses scraping harga komoditas otomatis.</p>
398
+ </div>
399
+
400
  {/* SECTION 1: Status & Quick Actions Bento */}
401
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
402
 
403
  {/* Status Overview Card */}
404
+ <div className="card-panel p-6 relative overflow-hidden flex flex-col justify-between group hover:shadow-md transition-all duration-300">
405
  <div>
406
  <div className="flex items-center justify-between mb-4">
407
+ <h2 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
408
+ <Activity className="w-4 h-4 text-primary" /> Status Scheduler
409
  </h2>
410
  <span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${
411
+ status?.is_running
412
+ ? 'bg-emerald-50 text-emerald-600 border border-emerald-200'
413
+ : 'bg-rose-50 text-rose-600 border border-rose-200'
414
  }`}>
415
  {status?.is_running ? 'RUNNING' : 'STOPPED'}
416
  </span>
417
  </div>
418
 
419
  <div className="space-y-3 mt-4">
420
+ <div className="bg-slate-50 rounded-xl p-3 border border-slate-100">
421
  <span className="text-xs text-slate-500 block">Jadwal Aktif</span>
422
+ <span className="text-lg font-black text-slate-800 mt-0.5">
423
  {status?.active_schedules_count || 0} <span className="text-xs font-normal text-slate-400">tugas rutin</span>
424
  </span>
425
  </div>
426
 
427
+ <div className="bg-slate-50 rounded-xl p-3 border border-slate-100">
428
  <span className="text-xs text-slate-500 block">Eksekusi Rutin Berikutnya</span>
429
+ <span className="text-sm font-bold text-primary block truncate mt-0.5">
430
  {status?.next_run ? new Date(status.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : 'Tidak ada jadwal terdaftar'}
431
  </span>
432
  </div>
433
  </div>
434
  </div>
435
 
436
+ {/* Toggle Button */}
437
  <button
438
  onClick={handleToggleScheduler}
439
+ className={`w-full mt-4 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center gap-2 border transition-all duration-200 ${
440
  status?.is_running
441
+ ? 'bg-rose-50 hover:bg-rose-100 text-rose-600 border-rose-200'
442
+ : 'bg-emerald-50 hover:bg-emerald-100 text-emerald-600 border-emerald-200'
443
  }`}
444
  >
445
  {status?.is_running ? <Square className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
446
  {status?.is_running ? 'Matikan Scheduler Otomatis' : 'Nyalakan Scheduler Otomatis'}
447
  </button>
448
+
449
+ {/* Gradient icon background */}
450
+ <div className="absolute -top-4 -right-4 w-24 h-24 bg-gradient-to-br from-emerald-400 to-green-600 rounded-2xl opacity-[0.07] rotate-12 pointer-events-none group-hover:-translate-y-1 transition-transform duration-300" />
451
  </div>
452
 
453
  {/* Manual Scrape Card */}
454
+ <div className="card-panel p-6 flex flex-col justify-between relative overflow-hidden group hover:shadow-md transition-all duration-300">
 
 
 
 
455
  <div>
456
+ <h2 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2 mb-2">
457
+ <Zap className="w-4 h-4 text-indigo-500" /> Aksi Cepat Manual
458
  </h2>
459
+ <p className="text-xs text-slate-500 leading-relaxed mb-5">
460
  Picu mesin scraper sekarang juga untuk mengambil harga terbaru dari server website sumber tanpa menunggu jadwal.
461
  </p>
462
 
463
  {/* Last Run Snippet */}
464
  {status?.last_result?.stats && (
465
+ <div className="bg-slate-50 rounded-xl p-3 border border-slate-100 mb-4 text-xs">
466
+ <div className="flex items-center justify-between text-slate-500 mb-1.5 pb-1.5 border-b border-slate-200/60 font-medium">
467
  <span>Statistik Scrape Terakhir</span>
468
  <button
469
  onClick={() => { setSelectedStats(status.last_result.stats); setShowStatsModal(true); }}
470
+ className="text-primary hover:underline flex items-center gap-1 font-semibold"
471
  >
472
  Detail Breakdown <Info className="w-3 h-3" />
473
  </button>
474
  </div>
475
  <div className="grid grid-cols-4 gap-1 text-center pt-0.5">
476
+ <div><span className="block text-[10px] text-slate-400">Insert</span><span className="font-bold text-emerald-600">{status.last_result.stats.total_insert}</span></div>
477
+ <div><span className="block text-[10px] text-slate-400">Update</span><span className="font-bold text-indigo-600">{status.last_result.stats.total_update}</span></div>
478
+ <div><span className="block text-[10px] text-slate-400">Skip</span><span className="font-bold text-slate-500">{status.last_result.stats.total_skip}</span></div>
479
+ <div><span className="block text-[10px] text-slate-400">Harga 0</span><span className="font-bold text-amber-600">{status.last_result.stats.total_harga_0}</span></div>
480
  </div>
481
  </div>
482
  )}
 
486
  <button
487
  onClick={handleManualScrape}
488
  disabled={loadingScrape}
489
+ className="w-full bg-primary hover:bg-blue-800 text-white font-semibold py-3 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-primary/10 disabled:opacity-50 transition-all duration-200 active:scale-[0.98]"
490
  >
491
  {loadingScrape ? (
492
  <>
 
506
  type="date"
507
  value={customDate}
508
  onChange={(e) => setCustomDate(e.target.value)}
509
+ className="flex-1 form-input text-xs h-[42px]"
510
  />
511
  <button
512
  onClick={handleCustomScrape}
513
  disabled={loadingScrape || !customDate}
514
+ className="bg-indigo-50 hover:bg-indigo-100 text-indigo-700 font-semibold px-4 rounded-xl flex items-center justify-center gap-2 shadow-sm disabled:opacity-50 transition-all duration-200 active:scale-[0.98] border border-indigo-200 text-xs h-[42px]"
515
  >
516
  <Calendar className="w-3.5 h-3.5" /> Scrape Tanggal
517
  </button>
 
520
  {loadingScrape && (
521
  <button
522
  onClick={handleStopScrape}
523
+ className="w-full bg-rose-50 hover:bg-rose-100 text-rose-600 font-medium py-2 px-4 rounded-xl flex items-center justify-center gap-2 border border-rose-200 transition-all duration-200 animate-pulse"
524
  >
525
  <Square className="w-3.5 h-3.5 fill-current" />
526
  Hentikan Proses
527
  </button>
528
  )}
529
  </div>
530
+
531
+ {/* Gradient icon background */}
532
+ <div className="absolute -top-4 -right-4 w-24 h-24 bg-gradient-to-br from-blue-400 to-indigo-600 rounded-2xl opacity-[0.07] rotate-12 pointer-events-none group-hover:-translate-y-1 transition-transform duration-300" />
533
  </div>
534
 
535
  </div>
536
 
537
  {/* SECTION 2: Multiple Schedules Manager */}
538
+ <div className="card-panel p-6">
539
  <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
540
  <div>
541
+ <h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
542
+ <Calendar className="w-5 h-5 text-primary" />
543
+ Manajemen Jadwal Rutin
544
  </h2>
545
+ <p className="text-xs text-slate-500 mt-1">
546
  Atur waktu pengambilan harga otomatis. Anda dapat menambahkan lebih dari satu jadwal per hari.
547
  </p>
548
  </div>
549
 
550
  {/* Quick Presets */}
551
+ <div className="flex flex-wrap gap-1.5 bg-slate-50 p-1.5 rounded-xl border border-slate-100 self-start sm:self-auto">
552
+ <span className="text-[10px] font-bold text-slate-400 px-2 self-center uppercase tracking-wider">Preset:</span>
553
  <button
554
  onClick={() => addPresetCron('08', '00', 'Pagi Hari (08:00)')}
555
+ className="px-2.5 py-1 bg-white hover:bg-primary/5 hover:text-primary text-slate-600 text-[11px] rounded-lg transition-all duration-200 border border-slate-200 font-medium"
556
  >
557
  08:00
558
  </button>
559
  <button
560
  onClick={() => addPresetCron('12', '00', 'Siang Hari (12:00)')}
561
+ className="px-2.5 py-1 bg-white hover:bg-primary/5 hover:text-primary text-slate-600 text-[11px] rounded-lg transition-all duration-200 border border-slate-200 font-medium"
562
  >
563
  12:00
564
  </button>
565
  <button
566
  onClick={() => addPresetCron('17', '00', 'Sore Hari (17:00)')}
567
+ className="px-2.5 py-1 bg-white hover:bg-primary/5 hover:text-primary text-slate-600 text-[11px] rounded-lg transition-all duration-200 border border-slate-200 font-medium"
568
  >
569
  17:00
570
  </button>
 
572
  </div>
573
 
574
  {/* Add Schedule Form */}
575
+ <form onSubmit={handleAddSchedule} className="grid grid-cols-1 md:grid-cols-12 gap-3 mb-6 bg-slate-50/80 p-4 rounded-xl border border-slate-100">
576
  <div className="md:col-span-3">
577
+ <label className="block text-[11px] font-semibold text-slate-500 mb-1 uppercase tracking-wider">Jam (WIB)</label>
578
  <select
579
  value={selectedHour}
580
  onChange={(e) => setSelectedHour(e.target.value)}
581
+ className="form-select text-sm"
582
  >
583
  {Array.from({ length: 24 }).map((_, i) => (
584
+ <option key={i} value={i.toString().padStart(2, '0')}>
585
  {i.toString().padStart(2, '0')}
586
  </option>
587
  ))}
588
  </select>
589
  </div>
590
  <div className="md:col-span-3">
591
+ <label className="block text-[11px] font-semibold text-slate-500 mb-1 uppercase tracking-wider">Menit</label>
592
  <select
593
  value={selectedMinute}
594
  onChange={(e) => setSelectedMinute(e.target.value)}
595
+ className="form-select text-sm"
596
  >
597
  {Array.from({ length: 60 }).map((_, i) => (
598
+ <option key={i} value={i.toString().padStart(2, '0')}>
599
  {i.toString().padStart(2, '0')}
600
  </option>
601
  ))}
602
  </select>
603
  </div>
604
  <div className="md:col-span-4">
605
+ <label className="block text-[11px] font-semibold text-slate-500 mb-1 uppercase tracking-wider">Label Jadwal</label>
606
  <input
607
  type="text"
608
  value={newLabel}
609
  onChange={(e) => setNewLabel(e.target.value)}
610
  placeholder="Contoh: Scraping Pagi"
611
+ className="form-input text-sm"
612
  />
613
  </div>
614
  <div className="md:col-span-2 flex items-end">
615
  <button
616
  type="submit"
617
+ className="w-full bg-primary hover:bg-blue-800 text-white font-bold py-2 px-3 rounded-lg text-xs flex items-center justify-center gap-1 shadow-sm transition-all duration-200"
618
  >
619
  <Plus className="w-3.5 h-3.5 stroke-[3]" /> Tambah
620
  </button>
 
625
  <div className="overflow-x-auto">
626
  <table className="w-full text-left border-collapse">
627
  <thead>
628
+ <tr className="border-b border-slate-200 text-[11px] text-slate-400 font-bold uppercase tracking-wider">
629
  <th className="pb-3 px-3">Label Jadwal</th>
630
  <th className="pb-3 px-3">Waktu (WIB)</th>
631
  <th className="pb-3 px-3">Estimasi Run Berikutnya</th>
632
  <th className="pb-3 px-3 text-right">Aksi</th>
633
  </tr>
634
  </thead>
635
+ <tbody className="divide-y divide-slate-100 text-sm text-slate-700">
636
  {schedules.length === 0 ? (
637
  <tr>
638
+ <td colSpan={4} className="text-center py-8 text-slate-400 italic text-sm">
639
  Belum ada jadwal yang didaftarkan. Gunakan form di atas untuk menambah jadwal.
640
  </td>
641
  </tr>
642
  ) : (
643
  schedules.map((item) => (
644
+ <tr key={item.id} className="hover:bg-slate-50 transition-colors duration-150">
645
+ <td className="py-3.5 px-3 font-semibold text-slate-900">
646
  {item.label}
647
  </td>
648
+ <td className="py-3.5 px-3">
649
+ <span className="font-mono text-primary font-bold bg-primary/5 py-1 px-2.5 rounded-lg border border-primary/10 text-sm">
650
  {(() => {
651
  const parts = item.cron_expression.split(' ');
652
  if (parts.length >= 2 && !isNaN(parseInt(parts[0])) && !isNaN(parseInt(parts[1]))) {
 
656
  })()}
657
  </span>
658
  </td>
659
+ <td className="py-3.5 px-3 text-slate-500 text-sm">
660
  {item.next_run ? new Date(item.next_run).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }) : '-'}
661
  </td>
662
+ <td className="py-3.5 px-3 text-right">
663
  <button
664
  onClick={() => handleRemoveSchedule(item.id)}
665
+ className="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-all duration-200"
666
  title="Hapus Jadwal"
667
  >
668
  <Trash2 className="w-4 h-4" />
 
676
  </div>
677
 
678
  {/* Hint */}
679
+ <div className="mt-4 pt-4 border-t border-slate-100 flex items-start gap-2 text-[11px] text-slate-400">
680
+ <Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-slate-300" />
681
  <span>
682
+ Format standar Cron: <code className="bg-slate-100 px-1.5 py-0.5 rounded text-slate-600 text-[10px] font-mono">(Menit) (Jam) (Hari) (Bulan) (Hari/Minggu)</code>. Waktu server saat ini mengikuti zona waktu sistem lokal.
683
  </span>
684
  </div>
685
  </div>
 
687
  </div>
688
 
689
  {/* RIGHT COLUMN: Terminal Logs */}
690
+ <div className="card-panel p-6 flex flex-col h-[500px] lg:h-[700px]">
691
 
692
  {/* Logs Header */}
693
+ <div className="flex items-center justify-between pb-4 mb-4 border-b border-slate-200/60">
694
+ <h2 className="text-sm font-bold text-slate-900 flex items-center gap-2">
695
+ <Terminal className="w-4 h-4 text-indigo-500" />
696
  Log Eksekusi Real-time
697
  </h2>
698
 
699
+ {/* Filter Controls */}
700
  <div className="flex items-center gap-1.5">
701
+ <div className="flex bg-slate-100 rounded-lg p-0.5 border border-slate-200">
702
  {['ALL', 'INFO', 'WARNING', 'ERROR'].map((lvl) => (
703
  <button
704
  key={lvl}
705
  onClick={() => setLogLevelFilter(lvl)}
706
+ className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all duration-200 ${
707
  logLevelFilter === lvl
708
+ ? 'bg-primary text-white shadow-sm'
709
+ : 'text-slate-500 hover:text-slate-700'
710
  }`}
711
  >
712
  {lvl}
 
716
  </div>
717
  </div>
718
 
719
+ {/* Terminal Box — stays dark for readability */}
720
+ <div ref={logsContainerRef} className="flex-1 bg-dark rounded-xl p-4 font-mono text-xs overflow-y-auto border border-slate-800 shadow-inner flex flex-col space-y-2 terminal-scrollbar">
721
  {logs.length === 0 ? (
722
+ <div className="m-auto text-slate-500 text-center italic">
723
  [ Log buffer kosong / Menunggu aktivitas baru... ]
724
  </div>
725
  ) : (
 
732
  const timeStr = new Date(log.timestamp).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
733
 
734
  return (
735
+ <div key={idx} className="leading-relaxed break-words hover:bg-white/[0.03] px-1 rounded transition-colors duration-100">
736
  <span className="text-slate-600 select-none">[{timeStr}]</span>{' '}
737
  <span className={`font-semibold ${levelColor} select-none`}>[{log.level}]</span>{' '}
738
  <span className="text-slate-500 select-none">({log.source})</span>{' '}
 
744
  <div ref={logsEndRef} />
745
  </div>
746
 
747
+ {/* Logs Footer */}
748
+ <div className="mt-3 flex items-center justify-between text-[11px] text-slate-400 px-1">
749
+ <span className="flex items-center gap-1.5">
750
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> Live polling aktif
751
  </span>
752
  <span>Total buffer: {logs.length} baris</span>
753
  </div>
 
757
 
758
  {/* --- STATS BREAKDOWN MODAL --- */}
759
  {showStatsModal && selectedStats && (
760
+ <div className="fixed inset-0 z-50 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4">
761
+ <div className="bg-white w-full max-w-2xl rounded-2xl p-6 border border-slate-200 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
762
 
763
  <button
764
  onClick={() => setShowStatsModal(false)}
765
+ className="absolute top-4 right-4 p-2 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-all duration-200"
766
  >
767
  <X className="w-5 h-5" />
768
  </button>
769
 
770
+ <h3 className="text-lg font-bold text-slate-900 mb-1 flex items-center gap-2">
771
+ <CheckCircle className="w-5 h-5 text-emerald-500" /> Laporan Eksekusi Scraping
772
  </h3>
773
+ <p className="text-xs text-slate-500 mb-6 pb-4 border-b border-slate-100">
774
  Breakdown lengkap hasil sinkronisasi harga komoditas dan pasar hari ini.
775
  </p>
776
 
777
  {/* Grid Summaries */}
778
  <div className="grid grid-cols-4 gap-4 mb-6">
779
+ <div className="bg-emerald-50 p-3 rounded-xl border border-emerald-100 text-center">
780
+ <span className="text-[11px] text-emerald-600 block font-medium">Insert Baru</span>
781
+ <span className="text-xl font-black text-emerald-700">{selectedStats.total_insert}</span>
782
  </div>
783
+ <div className="bg-indigo-50 p-3 rounded-xl border border-indigo-100 text-center">
784
+ <span className="text-[11px] text-indigo-600 block font-medium">Diperbarui</span>
785
+ <span className="text-xl font-black text-indigo-700">{selectedStats.total_update}</span>
786
  </div>
787
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-200 text-center">
788
+ <span className="text-[11px] text-slate-500 block font-medium">Data Lewat / Skip</span>
789
+ <span className="text-xl font-black text-slate-600">{selectedStats.total_skip}</span>
790
  </div>
791
+ <div className="bg-amber-50 p-3 rounded-xl border border-amber-200 text-center">
792
+ <span className="text-[11px] text-amber-600 block font-medium">Harga Rp 0</span>
793
+ <span className="text-xl font-black text-amber-700">{selectedStats.total_harga_0}</span>
794
  </div>
795
  </div>
796
 
 
798
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
799
 
800
  {/* Pasar Sukses */}
801
+ <div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
802
+ <span className="font-bold block mb-2 text-[11px] uppercase tracking-wider text-emerald-600 flex items-center gap-1.5">
803
  <Check className="w-3.5 h-3.5" /> Pasar Tersinkronisasi ({selectedStats.pasar_berhasil?.length || 0})
804
  </span>
805
  {selectedStats.pasar_berhasil?.length > 0 ? (
806
+ <ul className="space-y-1 text-slate-700">
807
  {selectedStats.pasar_berhasil.map((psr: string, i: number) => (
808
  <li key={i} className="flex items-center gap-1.5">
809
+ <span className="w-1 h-1 rounded-full bg-emerald-500" /> {psr}
810
  </li>
811
  ))}
812
  </ul>
813
  ) : (
814
+ <span className="text-slate-400 italic">- Tidak ada -</span>
815
  )}
816
  </div>
817
 
818
  {/* Pasar Harga 0 */}
819
+ <div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
820
+ <span className="font-bold block mb-2 text-[11px] uppercase tracking-wider text-amber-600 flex items-center gap-1.5">
821
  <AlertTriangle className="w-3.5 h-3.5" /> Pasar Harga Kosong/Nol ({selectedStats.pasar_harga_0?.length || 0})
822
  </span>
823
  {selectedStats.pasar_harga_0?.length > 0 ? (
824
+ <ul className="space-y-1 text-slate-600">
825
  {selectedStats.pasar_harga_0.map((psr: string, i: number) => (
826
  <li key={i} className="flex items-center gap-1.5">
827
+ <span className="w-1 h-1 rounded-full bg-amber-500" /> {psr}
828
  </li>
829
  ))}
830
  </ul>
831
  ) : (
832
+ <span className="text-slate-400 italic">- Tidak ada -</span>
833
  )}
834
  </div>
835
 
836
  </div>
837
 
838
  {/* Bottom info */}
839
+ <div className="mt-6 pt-4 border-t border-slate-100 flex items-center justify-between text-slate-500 text-[11px]">
840
+ <span>Total Komoditas Diperbarui: <strong className="text-slate-700">{selectedStats.komoditas_berhasil_count || 0} item</strong></span>
841
+ <span>Komoditas Harga Nol: <strong className="text-slate-700">{selectedStats.komoditas_harga_0_count || 0} item</strong></span>
842
  </div>
843
 
844
  <div className="mt-6 text-right">
845
  <button
846
  onClick={() => setShowStatsModal(false)}
847
+ className="bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2 px-5 rounded-xl transition-all duration-200 border border-slate-200 text-sm"
848
  >
849
  Tutup Jendela
850
  </button>
frontend/tailwind.config.js CHANGED
@@ -4,7 +4,15 @@ module.exports = {
4
  './app/**/*.{js,ts,jsx,tsx,mdx}',
5
  ],
6
  theme: {
7
- extend: {},
 
 
 
 
 
 
 
 
8
  },
9
  plugins: [],
10
  }
 
4
  './app/**/*.{js,ts,jsx,tsx,mdx}',
5
  ],
6
  theme: {
7
+ extend: {
8
+ colors: {
9
+ primary: '#1e3a8a',
10
+ dark: '#0f172a',
11
+ },
12
+ fontFamily: {
13
+ sans: ['Inter', 'sans-serif'],
14
+ },
15
+ },
16
  },
17
  plugins: [],
18
  }