first commit

This commit is contained in:
Achmad Sofyan Hakiki 2026-06-02 10:38:04 +07:00
parent 6ee0066ba2
commit 3aa062465b
23 changed files with 3106 additions and 1032 deletions

10
ecosystem.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
apps: [
{
name: "sortayok",
cwd: "D:/sortayok",
script: "npm.cmd",
args: "start"
}
]
}

8
next.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
};
module.exports = nextConfig;

2250
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,25 +3,30 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "next lint"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.12"
"bcryptjs": "^3.0.3",
"lucide-react": "^0.460.0",
"mysql2": "^3.22.2",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"recharts": "^2.15.0"
},
"devDependencies": {
"typescript": "^5",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"autoprefixer": "^10.4.20",
"eslint": "^9",
"eslint-config-next": "15.5.12",
"@eslint/eslintrc": "^3"
"eslint-config-next": "15.1.4",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/db';
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, password } = body;
const users: any = await executeQuery(
`SELECT id, nama, username, password, role FROM users WHERE username = ? AND password = ?`,
[username, password]
);
if (users.length === 0) {
return NextResponse.json(
{ success: false, message: 'Username atau password salah!' },
{ status: 401 }
);
}
const user = users[0];
return NextResponse.json({
success: true,
message: 'Login berhasil',
user: { id: user.id, nama: user.nama, username: user.username, role: user.role }
});
} catch (error) {
return NextResponse.json(
{ success: false, message: error instanceof Error ? error.message : 'Server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/db';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const date = searchParams.get('date') || '';
const isAll = date === 'all';
// ✅ FIX TIMEZONE: Selalu convert ke WIB (+07:00) sebelum filter tanggal
let finalCondition: string;
if (isAll) {
finalCondition = '1=1';
} else if (date) {
// Filter tanggal spesifik dari date picker
finalCondition = `DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) = '${date}'`;
} else {
// Default: hari ini dalam WIB (bukan UTC!)
finalCondition = `DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) = DATE(CONVERT_TZ(NOW(), '+00:00', '+07:00'))`;
}
// 1. Hitung jumlah per grade
const gradeCount: any = await executeQuery(`
SELECT grade, COUNT(*) as count
FROM telur
WHERE ${finalCondition}
GROUP BY grade
`);
const current = { grade_a: 0, grade_b: 0, grade_c: 0, tidak_layak: 0, total: 0 };
gradeCount.forEach((row: any) => {
switch (row.grade) {
case 'A': current.grade_a = Number(row.count); break;
case 'B': current.grade_b = Number(row.count); break;
case 'C': current.grade_c = Number(row.count); break;
case 'TL': current.tidak_layak = Number(row.count); break;
}
});
current.total = current.grade_a + current.grade_b + current.grade_c + current.tidak_layak;
// 2. History untuk chart — group by jam WIB
let historyCondition: string;
if (isAll) {
historyCondition = 'created_at >= NOW() - INTERVAL 7 DAY';
} else if (date) {
historyCondition = `DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) = '${date}'`;
} else {
historyCondition = `CONVERT_TZ(created_at, '+00:00', '+07:00') >= DATE_SUB(CONVERT_TZ(NOW(), '+00:00', '+07:00'), INTERVAL 12 HOUR)`;
}
const historyData: any = await executeQuery(`
SELECT
DATE_FORMAT(CONVERT_TZ(created_at, '+00:00', '+07:00'), '%H:%i') as time,
grade,
COUNT(*) as count
FROM telur
WHERE ${historyCondition}
GROUP BY DATE_FORMAT(CONVERT_TZ(created_at, '+00:00', '+07:00'), '%H:%i'), grade
ORDER BY MIN(created_at) ASC
`);
const chartData = formatChartData(historyData);
// 3. Pie chart
const totalCount = current.total || 1;
const pieData = [
{ name: 'Grade A', value: current.grade_a, percentage: ((current.grade_a / totalCount) * 100).toFixed(1) },
{ name: 'Grade B', value: current.grade_b, percentage: ((current.grade_b / totalCount) * 100).toFixed(1) },
{ name: 'Grade C', value: current.grade_c, percentage: ((current.grade_c / totalCount) * 100).toFixed(1) },
{ name: 'Tidak Layak', value: current.tidak_layak, percentage: ((current.tidak_layak / totalCount) * 100).toFixed(1) },
];
const stats = {
today: current.total,
gradeAPercent: ((current.grade_a / totalCount) * 100).toFixed(1),
lastUpdate: new Date().toISOString()
};
return NextResponse.json({
success: true,
current,
history: chartData,
pieChart: pieData,
stats,
activeDate: isAll ? 'all' : (date || 'today')
});
} catch (error) {
console.error('API Error:', error);
return NextResponse.json(
{ success: false, error: 'Database connection error', message: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
function formatChartData(data: any[]) {
const grouped: any = {};
data.forEach((row: any) => {
const t = row.time;
if (!grouped[t]) grouped[t] = { time: t, gradeA: 0, gradeB: 0, gradeC: 0, tidakLayak: 0 };
switch (row.grade) {
case 'A': grouped[t].gradeA = Number(row.count); break;
case 'B': grouped[t].gradeB = Number(row.count); break;
case 'C': grouped[t].gradeC = Number(row.count); break;
case 'TL': grouped[t].tidakLayak = Number(row.count); break;
}
});
return Object.values(grouped);
}

View File

@ -0,0 +1,74 @@
import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/db';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const grade = searchParams.get('grade') || '';
const date = searchParams.get('date') || '';
const offset = (page - 1) * limit;
// Build WHERE clause
let where = 'WHERE 1=1';
const params: any[] = [];
if (grade) {
where += ' AND grade = ?';
params.push(grade);
}
if (date) {
where += " AND DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) = ?";
params.push(date);
}
// Total count
const countResult: any = await executeQuery(
`SELECT COUNT(*) as total FROM telur ${where}`,
params
);
const total = countResult[0].total;
// Fetch data with pagination
const rows: any = await executeQuery(
`SELECT id, created_at, berat, grade
FROM telur
${where}
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}`,
params
);
// Summary hari ini (WIB)
const summary: any = await executeQuery(`
SELECT grade, COUNT(*) as count
FROM telur
WHERE DATE(CONVERT_TZ(created_at, '+00:00', '+07:00')) = CURDATE()
GROUP BY grade
`);
return NextResponse.json({
success: true,
data: rows,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
},
summary
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: 'Database error',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
const ENV_PATH = path.join(process.cwd(), '.env.local');
// GET - Baca konfigurasi dari .env.local
export async function GET() {
try {
const content = fs.readFileSync(ENV_PATH, 'utf-8');
const lines = content.split('\n');
const config: Record<string, string> = {};
lines.forEach(line => {
const [key, ...rest] = line.split('=');
if (key && rest.length) config[key.trim()] = rest.join('=').trim();
});
return NextResponse.json({
success: true,
data: {
host: config['DB_HOST'] || '',
port: config['DB_PORT'] || '3306',
user: config['DB_USER'] || '',
password: config['DB_PASSWORD'] || '',
database: config['DB_NAME'] || '',
}
});
} catch (error) {
return NextResponse.json({ success: false, message: 'Gagal membaca konfigurasi' }, { status: 500 });
}
}
// POST - Simpan konfigurasi ke .env.local
export async function POST(request: Request) {
try {
const body = await request.json();
const { host, port, user, password, database } = body;
const content = `DB_HOST=${host}
DB_PORT=${port}
DB_USER=${user}
DB_PASSWORD=${password}
DB_NAME=${database}
`;
fs.writeFileSync(ENV_PATH, content, 'utf-8');
return NextResponse.json({
success: true,
message: 'Konfigurasi berhasil disimpan. Restart server untuk menerapkan perubahan.'
});
} catch (error) {
return NextResponse.json({ success: false, message: 'Gagal menyimpan konfigurasi' }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import mysql from 'mysql2/promise';
// POST - Test koneksi database dengan config yang dikirim
export async function POST(request: Request) {
try {
const body = await request.json();
const { host, port, user, password, database } = body;
const connection = await mysql.createConnection({
host,
port: parseInt(port),
user,
password,
database,
connectTimeout: 5000
});
await connection.ping();
await connection.end();
return NextResponse.json({
success: true,
message: `Koneksi ke ${host}:${port}/${database} berhasil!`
});
} catch (error) {
return NextResponse.json({
success: false,
message: `Koneksi gagal: ${error instanceof Error ? error.message : 'Unknown error'}`
}, { status: 500 });
}
}

View File

@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/db';
// GET - Ambil semua user
export async function GET() {
try {
const users: any = await executeQuery(
`SELECT id, nama, username, role, created_at FROM users ORDER BY id ASC`
);
return NextResponse.json({ success: true, data: users });
} catch (error) {
return NextResponse.json({ success: false, message: error instanceof Error ? error.message : 'Error' }, { status: 500 });
}
}
// POST - Tambah user baru
export async function POST(request: Request) {
try {
const body = await request.json();
const { nama, username, password, role } = body;
if (!nama || !username || !password) {
return NextResponse.json({ success: false, message: 'Nama, username, dan password wajib diisi' }, { status: 400 });
}
// Cek username sudah ada
const existing: any = await executeQuery(
`SELECT id FROM users WHERE username = ?`, [username]
);
if (existing.length > 0) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 400 });
}
await executeQuery(
`INSERT INTO users (nama, username, password, role) VALUES (?, ?, ?, ?)`,
[nama, username, password, role || 'operator']
);
return NextResponse.json({ success: true, message: 'User berhasil ditambahkan' });
} catch (error) {
return NextResponse.json({ success: false, message: error instanceof Error ? error.message : 'Error' }, { status: 500 });
}
}
// PUT - Update user (nama, role, atau ganti password)
export async function PUT(request: Request) {
try {
const body = await request.json();
const { id, nama, role, password } = body;
if (!id) {
return NextResponse.json({ success: false, message: 'ID user wajib diisi' }, { status: 400 });
}
if (password) {
await executeQuery(
`UPDATE users SET nama = ?, role = ?, password = ?, updated_at = NOW() WHERE id = ?`,
[nama, role, password, id]
);
} else {
await executeQuery(
`UPDATE users SET nama = ?, role = ?, updated_at = NOW() WHERE id = ?`,
[nama, role, id]
);
}
return NextResponse.json({ success: true, message: 'User berhasil diupdate' });
} catch (error) {
return NextResponse.json({ success: false, message: error instanceof Error ? error.message : 'Error' }, { status: 500 });
}
}
// DELETE - Hapus user
export async function DELETE(request: Request) {
try {
const body = await request.json();
const { id } = body;
if (!id) {
return NextResponse.json({ success: false, message: 'ID user wajib diisi' }, { status: 400 });
}
await executeQuery(`DELETE FROM users WHERE id = ?`, [id]);
return NextResponse.json({ success: true, message: 'User berhasil dihapus' });
} catch (error) {
return NextResponse.json({ success: false, message: error instanceof Error ? error.message : 'Error' }, { status: 500 });
}
}

227
src/app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,227 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
Home, FileText, Settings, LogOut, ChevronRight,
CheckCircle, AlertCircle, XCircle, CalendarDays, X
} from 'lucide-react'
import ClassificationPie from '@/components/ClassificationPie'
import GrafanaChart from '@/components/GrafanaChart'
export default function DashboardPage() {
const router = useRouter()
const [activeMenu, setActiveMenu] = useState('Dashboard')
const [eggData, setEggData] = useState({
grade_a: 0, grade_b: 0, grade_c: 0, tidak_layak: 0, total: 0
})
const [pieData, setPieData] = useState<any[]>([])
const [historyData, setHistoryData] = useState<any[]>([])
const [lastUpdate, setLastUpdate] = useState<string>('')
const [loading, setLoading] = useState(true)
// ✅ State filter tanggal
const [filterDate, setFilterDate] = useState('') // '' = hari ini
// ===== FETCH DATA =====
const fetchData = useCallback(async () => {
try {
const params = new URLSearchParams()
if (filterDate) params.set('date', filterDate)
// jika filterDate kosong → API default ke hari ini (WIB)
const res = await fetch(`/api/egg-data?${params.toString()}`)
const data = await res.json()
if (data.success) {
setEggData(data.current)
setPieData(data.pieChart)
setHistoryData(data.history)
setLastUpdate(data.stats?.lastUpdate || '')
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [filterDate])
useEffect(() => {
fetchData()
// Auto-refresh tiap 5 detik HANYA saat tidak ada filter tanggal spesifik
if (!filterDate) {
const interval = setInterval(fetchData, 5000)
return () => clearInterval(interval)
}
}, [fetchData, filterDate])
const handleLogout = () => {
localStorage.removeItem('isAuthenticated')
localStorage.removeItem('user')
router.push('/')
}
// Label tanggal aktif untuk ditampilkan di header
const activeDateLabel = filterDate
? new Date(filterDate + 'T00:00:00').toLocaleDateString('id-ID', {
day: '2-digit', month: 'long', year: 'numeric'
})
: 'Hari Ini'
const stats = [
{ grade: 'A', count: eggData.grade_a, color: 'bg-green-600', icon: CheckCircle },
{ grade: 'B', count: eggData.grade_b, color: 'bg-yellow-500', icon: AlertCircle },
{ grade: 'C', count: eggData.grade_c, color: 'bg-orange-500', icon: AlertCircle },
{ grade: 'Tidak Layak', count: eggData.tidak_layak, color: 'bg-red-600', icon: XCircle },
]
if (loading) {
return <div className="p-10 text-center text-gray-500">Memuat data...</div>
}
return (
<div className="flex min-h-screen bg-gray-100">
{/* Sidebar */}
<aside className="w-72 bg-gradient-to-b from-slate-700 to-slate-800 text-white p-6 flex flex-col">
<h2 className="text-xl font-bold mb-8">Monitoring System</h2>
<nav className="flex-1 space-y-2">
<button
onClick={() => setActiveMenu('Dashboard')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg ${
activeMenu === 'Dashboard' ? 'bg-blue-600' : 'hover:bg-slate-600'
}`}
>
<Home size={20} /> Dashboard
</button>
<button
onClick={() => router.push('/reporting')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-600"
>
<FileText size={20} /> Reporting
</button>
<button
onClick={() => router.push('/settings')}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg hover:bg-slate-600"
>
<span className="flex items-center gap-3">
<Settings size={20} /> Settings
</span>
<ChevronRight size={16} />
</button>
</nav>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-red-600 mt-auto"
>
<LogOut size={20} /> Logout
</button>
</aside>
{/* Main */}
<main className="flex-1 p-8">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold">Dashboard Monitoring Kualitas Telur</h1>
{/* Label tanggal aktif */}
<p className="text-sm text-gray-500 mt-1">
Menampilkan data: <span className="font-semibold text-gray-700">{activeDateLabel}</span>
{!filterDate && (
<span className="ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
Live refresh 5s
</span>
)}
</p>
</div>
{/* ✅ Date picker + reset */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-white border border-gray-300 rounded-lg px-3 py-2 shadow-sm">
<CalendarDays size={16} className="text-gray-400" />
<input
type="date"
value={filterDate}
max={new Date().toISOString().split('T')[0]}
onChange={e => setFilterDate(e.target.value)}
className="text-sm text-gray-700 outline-none bg-transparent cursor-pointer"
/>
</div>
{filterDate && (
<button
onClick={() => setFilterDate('')}
className="flex items-center gap-1 text-sm text-gray-500 border border-gray-300 bg-white rounded-lg px-3 py-2 hover:bg-gray-50 shadow-sm"
>
<X size={14} /> Hari Ini
</button>
)}
{lastUpdate && (
<span className="text-xs text-gray-400">
Update: {new Date(lastUpdate).toLocaleTimeString('id-ID')}
</span>
)}
</div>
</div>
{/* Grade Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{stats.map((s, i) => {
const Icon = s.icon
return (
<div key={i} className={`${s.color} text-white p-6 rounded-xl shadow`}>
<div className="flex items-center gap-2 mb-2">
<Icon size={18} />
<span className="font-medium">Grade {s.grade}</span>
</div>
<div className="text-3xl font-bold">{s.count}</div>
</div>
)
})}
</div>
{/* Summary */}
<div className="bg-white p-6 rounded-xl mb-8 shadow">
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
{[
{ label: 'Grade A', value: eggData.grade_a },
{ label: 'Grade B', value: eggData.grade_b },
{ label: 'Grade C', value: eggData.grade_c },
{ label: 'Tidak Layak', value: eggData.tidak_layak },
{ label: 'Total', value: eggData.total },
].map((item, i) => (
<div key={i}>
<div className="text-sm text-gray-500">{item.label}</div>
<div className="text-xl font-bold">{item.value}</div>
</div>
))}
</div>
</div>
{/* Charts */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-xl shadow">
<h2 className="font-bold mb-4">Klasifikasi Telur</h2>
<ClassificationPie data={pieData} />
</div>
<div className="bg-white p-6 rounded-xl shadow">
<h2 className="font-bold mb-4">
Grafik Telur ({filterDate ? activeDateLabel : '12 Jam Terakhir'})
</h2>
<GrafanaChart data={historyData} />
</div>
</div>
</main>
</div>
)
}

View File

@ -1,26 +1,19 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

View File

@ -1,34 +1,23 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
// @ts-ignore
import './globals.css'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: 'SORTA',
description: 'Sistem monitoring kualitas telur ayam terintegrasi dengan Grafana',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
<html lang="id">
<body className={inter.className}>{children}</body>
</html>
);
)
}

View File

@ -1,103 +1,100 @@
import Image from "next/image";
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
const data = await res.json()
if (data.success) {
// Simpan info user ke localStorage
localStorage.setItem('isAuthenticated', 'true')
localStorage.setItem('user', JSON.stringify(data.user))
router.push('/dashboard')
} else {
setError(data.message || 'Username atau password salah!')
}
} catch (err) {
setError('Gagal terhubung ke server')
} finally {
setLoading(false)
}
}
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="bg-white p-8 rounded-lg shadow-xl w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Monitoring Telur Ayam
</h1>
<p className="text-gray-600">Silakan login untuk melanjutkan<br></br>U=admin-P=fyanjago</p>
</div>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
placeholder="Masukkan username"
required
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
placeholder="Masukkan password"
required
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Memuat...' : 'Login'}
</button>
</form>
</div>
</div>
)
}

296
src/app/reporting/page.tsx Normal file
View File

@ -0,0 +1,296 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Home, FileText, Settings, LogOut, ChevronRight,
ChevronLeft, ChevronRight as ChevronRightIcon,
Download
} from 'lucide-react'
interface EggRow {
id: number
created_at: string
berat: number
grade: 'A' | 'B' | 'C' | 'TL'
}
interface Pagination {
total: number
page: number
limit: number
totalPages: number
}
const GRADE_STYLE: Record<string, string> = {
A: 'bg-green-100 text-green-700 border border-green-300',
B: 'bg-yellow-100 text-yellow-700 border border-yellow-300',
C: 'bg-orange-100 text-orange-700 border border-orange-300',
TL: 'bg-red-100 text-red-700 border border-red-300',
}
const GRADE_LABEL: Record<string, string> = {
A: 'Grade A',
B: 'Grade B',
C: 'Grade C',
TL: 'Tidak Layak',
}
// Bulatkan max 2 desimal, hapus trailing zero (61.10 → 61.1, 55.95 → 55.95)
const formatBerat = (berat: number) => {
if (berat == null) return '-'
return parseFloat(berat.toFixed(2)).toString()
}
export default function ReportingPage() {
const router = useRouter()
const [activeMenu, setActiveMenu] = useState('Reporting')
const [rows, setRows] = useState<EggRow[]>([])
const [pagination, setPagination] = useState<Pagination>({
total: 0, page: 1, limit: 20, totalPages: 1
})
const [loading, setLoading] = useState(true)
// Filter state
const [filterGrade, setFilterGrade] = useState('')
const [filterDate, setFilterDate] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const fetchData = async (page = 1) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('limit', '20')
if (filterGrade) params.set('grade', filterGrade)
if (filterDate) params.set('date', filterDate)
const res = await fetch(`/api/reporting?${params.toString()}`)
const json = await res.json()
if (json.success) {
setRows(json.data)
setPagination(json.pagination)
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData(currentPage)
}, [currentPage, filterGrade, filterDate])
const handleLogout = () => router.push('/login')
const formatWaktu = (waktu: string) => {
const d = new Date(waktu)
const tanggal = d.toLocaleDateString('id-ID', {
day: '2-digit', month: 'short', year: 'numeric'
})
const jam = d.toLocaleTimeString('id-ID', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
return { tanggal, jam }
}
// Export CSV — fetch semua data sesuai filter aktif
const handleExport = async () => {
try {
const params = new URLSearchParams()
params.set('page', '1')
params.set('limit', pagination.total > 0 ? String(pagination.total) : '99999')
if (filterGrade) params.set('grade', filterGrade)
if (filterDate) params.set('date', filterDate)
const res = await fetch(`/api/reporting?${params.toString()}`)
const json = await res.json()
if (!json.success) return
const allRows: EggRow[] = json.data
const header = 'ID,Tanggal,Jam,Berat (g),Grade\n'
const csvRows = allRows.map((r, i) => {
const { tanggal, jam } = formatWaktu(r.created_at)
return `${i + 1},${tanggal},${jam},${formatBerat(r.berat)},${GRADE_LABEL[r.grade] || r.grade}`
}).join('\n')
const blob = new Blob([header + csvRows], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `laporan-telur-${filterDate || 'semua'}${filterGrade ? `-grade${filterGrade}` : ''}.csv`
a.click()
URL.revokeObjectURL(url)
} catch (err) {
console.error('Export gagal:', err)
}
}
return (
<div className="flex min-h-screen bg-gray-100">
{/* Sidebar */}
<aside className="w-72 bg-gradient-to-b from-slate-700 to-slate-800 text-white p-6 flex flex-col">
<h2 className="text-xl font-bold mb-8">Monitoring System</h2>
<nav className="flex-1 space-y-2">
<button
onClick={() => router.push('/dashboard')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-600"
>
<Home size={20} /> Dashboard
</button>
<button
onClick={() => setActiveMenu('Reporting')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg ${
activeMenu === 'Reporting' ? 'bg-blue-600' : 'hover:bg-slate-600'
}`}
>
<FileText size={20} /> Reporting
</button>
<button className="w-full flex items-center justify-between px-4 py-3 rounded-lg hover:bg-slate-600">
<div className="flex items-center gap-3">
<Settings size={20} /> Settings
</div>
<ChevronRight size={16} />
</button>
</nav>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-red-600 mt-auto"
>
<LogOut size={20} /> Logout
</button>
</aside>
{/* Main */}
<main className="flex-1 p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Laporan Klasifikasi Telur</h1>
<button
onClick={handleExport}
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm"
>
<Download size={16} /> Export CSV
</button>
</div>
{/* Filter */}
<div className="bg-white rounded-xl shadow p-4 mb-6 flex flex-wrap gap-4 items-end">
<div>
<label className="text-xs text-gray-500 mb-1 block">Filter Tanggal</label>
<input
type="date"
value={filterDate}
onChange={e => { setFilterDate(e.target.value); setCurrentPage(1) }}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Filter Grade</label>
<select
value={filterGrade}
onChange={e => { setFilterGrade(e.target.value); setCurrentPage(1) }}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Semua Grade</option>
<option value="A">Grade A</option>
<option value="B">Grade B</option>
<option value="C">Grade C</option>
<option value="TL">Tidak Layak</option>
</select>
</div>
<button
onClick={() => { setFilterGrade(''); setFilterDate(''); setCurrentPage(1) }}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Reset Filter
</button>
<div className="ml-auto text-sm text-gray-500 self-center">
Total: <span className="font-bold text-gray-800">{pagination.total}</span> data
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-700 text-white">
<tr>
<th className="px-4 py-3 text-left w-12">No</th>
<th className="px-4 py-3 text-left">Tanggal</th>
<th className="px-4 py-3 text-left">Jam</th>
<th className="px-4 py-3 text-left">Berat (g)</th>
<th className="px-4 py-3 text-left">Grade</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="text-center py-10 text-gray-400">
Memuat data...
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-gray-400">
Tidak ada data
</td>
</tr>
) : (
rows.map((row, i) => {
const { tanggal, jam } = formatWaktu(row.created_at)
const rowNum = (pagination.page - 1) * pagination.limit + i + 1
return (
<tr key={row.id} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 text-gray-500">{rowNum}</td>
<td className="px-4 py-3 text-gray-700">{tanggal}</td>
<td className="px-4 py-3 font-mono text-gray-700">{jam}</td>
<td className="px-4 py-3 text-gray-700">{formatBerat(row.berat)}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${GRADE_STYLE[row.grade] || ''}`}>
{GRADE_LABEL[row.grade] || row.grade}
</span>
</td>
</tr>
)
})
)}
</tbody>
</table>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
<span className="text-sm text-gray-500">
Halaman {pagination.page} dari {pagination.totalPages}
</span>
<div className="flex gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => p - 1)}
className="p-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
disabled={currentPage === pagination.totalPages}
onClick={() => setCurrentPage(p => p + 1)}
className="p-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRightIcon size={16} />
</button>
</div>
</div>
)}
</div>
</main>
</div>
)
}

446
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,446 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Home, FileText, Settings, LogOut, ChevronRight,
Users, Database, Plus, Pencil, Trash2, X, Check, Eye, EyeOff
} from 'lucide-react'
interface User {
id: number
nama: string
username: string
role: 'admin' | 'operator'
created_at: string
}
interface DbConfig {
host: string
port: string
user: string
password: string
database: string
}
const ROLE_STYLE: Record<string, string> = {
admin: 'bg-blue-100 text-blue-700 border border-blue-300',
operator: 'bg-gray-100 text-gray-700 border border-gray-300',
}
export default function SettingsPage() {
const router = useRouter()
const [activeTab, setActiveTab] = useState<'users' | 'database'>('users')
// ===== USER STATE =====
const [users, setUsers] = useState<User[]>([])
const [loadingUsers, setLoadingUsers] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editUser, setEditUser] = useState<User | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [userForm, setUserForm] = useState({ nama: '', username: '', password: '', role: 'operator' })
const [userMsg, setUserMsg] = useState({ text: '', type: '' })
// ===== DB CONFIG STATE =====
const [dbConfig, setDbConfig] = useState<DbConfig>({
host: '', port: '3306', user: '', password: '', database: ''
})
const [dbMsg, setDbMsg] = useState({ text: '', type: '' })
const [showDbPassword, setShowDbPassword] = useState(false)
const [testingDb, setTestingDb] = useState(false)
// ===== FETCH USERS =====
const fetchUsers = async () => {
setLoadingUsers(true)
try {
const res = await fetch('/api/settings/users')
const json = await res.json()
if (json.success) setUsers(json.data)
} catch (err) {
console.error(err)
} finally {
setLoadingUsers(false)
}
}
// ===== FETCH DB CONFIG from .env =====
const fetchDbConfig = async () => {
try {
const res = await fetch('/api/settings/database')
const json = await res.json()
if (json.success) setDbConfig(json.data)
} catch (err) {
console.error(err)
}
}
useEffect(() => {
fetchUsers()
fetchDbConfig()
}, [])
// ===== OPEN MODAL =====
const openAdd = () => {
setEditUser(null)
setUserForm({ nama: '', username: '', password: '', role: 'operator' })
setShowModal(true)
}
const openEdit = (u: User) => {
setEditUser(u)
setUserForm({ nama: u.nama, username: u.username, password: '', role: u.role })
setShowModal(true)
}
// ===== SAVE USER =====
const handleSaveUser = async () => {
try {
const method = editUser ? 'PUT' : 'POST'
const body = editUser
? { id: editUser.id, ...userForm }
: userForm
const res = await fetch('/api/settings/users', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const json = await res.json()
if (json.success) {
setUserMsg({ text: json.message, type: 'success' })
setShowModal(false)
fetchUsers()
} else {
setUserMsg({ text: json.message, type: 'error' })
}
} catch (err) {
setUserMsg({ text: 'Terjadi kesalahan', type: 'error' })
}
setTimeout(() => setUserMsg({ text: '', type: '' }), 3000)
}
// ===== DELETE USER =====
const handleDelete = async (id: number) => {
if (!confirm('Yakin hapus user ini?')) return
try {
const res = await fetch('/api/settings/users', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
})
const json = await res.json()
if (json.success) {
setUserMsg({ text: json.message, type: 'success' })
fetchUsers()
}
} catch (err) {
setUserMsg({ text: 'Gagal menghapus user', type: 'error' })
}
setTimeout(() => setUserMsg({ text: '', type: '' }), 3000)
}
// ===== SAVE DB CONFIG =====
const handleSaveDb = async () => {
try {
const res = await fetch('/api/settings/database', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dbConfig)
})
const json = await res.json()
setDbMsg({ text: json.message, type: json.success ? 'success' : 'error' })
} catch (err) {
setDbMsg({ text: 'Gagal menyimpan konfigurasi', type: 'error' })
}
setTimeout(() => setDbMsg({ text: '', type: '' }), 3000)
}
// ===== TEST DB CONNECTION =====
const handleTestDb = async () => {
setTestingDb(true)
try {
const res = await fetch('/api/settings/database/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dbConfig)
})
const json = await res.json()
setDbMsg({ text: json.message, type: json.success ? 'success' : 'error' })
} catch (err) {
setDbMsg({ text: 'Koneksi gagal', type: 'error' })
} finally {
setTestingDb(false)
}
setTimeout(() => setDbMsg({ text: '', type: '' }), 4000)
}
return (
<div className="flex min-h-screen bg-gray-100">
{/* Sidebar */}
<aside className="w-72 bg-gradient-to-b from-slate-700 to-slate-800 text-white p-6 flex flex-col">
<h2 className="text-xl font-bold mb-8">Monitoring System</h2>
<nav className="flex-1 space-y-2">
<button onClick={() => router.push('/dashboard')} className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-600">
<Home size={20} /> Dashboard
</button>
<button onClick={() => router.push('/reporting')} className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-600">
<FileText size={20} /> Reporting
</button>
<button className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-blue-600">
<div className="flex items-center gap-3"><Settings size={20} /> Settings</div>
<ChevronRight size={16} />
</button>
</nav>
<button onClick={() => router.push('/login')} className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-red-600 mt-auto">
<LogOut size={20} /> Logout
</button>
</aside>
{/* Main */}
<main className="flex-1 p-8">
<h1 className="text-3xl font-bold mb-6">Settings</h1>
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('users')}
className={`flex items-center gap-2 px-5 py-2 rounded-lg font-medium text-sm ${activeTab === 'users' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50 shadow'}`}
>
<Users size={16} /> Manajemen User
</button>
<button
onClick={() => setActiveTab('database')}
className={`flex items-center gap-2 px-5 py-2 rounded-lg font-medium text-sm ${activeTab === 'database' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50 shadow'}`}
>
<Database size={16} /> Konfigurasi Database
</button>
</div>
{/* ===== TAB: USERS ===== */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-bold text-lg">Daftar User</h2>
<button
onClick={openAdd}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm"
>
<Plus size={16} /> Tambah User
</button>
</div>
{/* Alert */}
{userMsg.text && (
<div className={`mb-4 px-4 py-2 rounded-lg text-sm ${userMsg.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{userMsg.text}
</div>
)}
<table className="w-full text-sm">
<thead className="bg-slate-700 text-white rounded">
<tr>
<th className="px-4 py-3 text-left">Nama</th>
<th className="px-4 py-3 text-left">Username</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Dibuat</th>
<th className="px-4 py-3 text-center">Aksi</th>
</tr>
</thead>
<tbody>
{loadingUsers ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">Memuat...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">Belum ada user</td></tr>
) : users.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-medium">{u.nama}</td>
<td className="px-4 py-3 text-gray-600">{u.username}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${ROLE_STYLE[u.role]}`}>
{u.role}
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(u.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' })}
</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center gap-2">
<button onClick={() => openEdit(u)} className="p-1.5 rounded-lg bg-yellow-100 hover:bg-yellow-200 text-yellow-700">
<Pencil size={14} />
</button>
<button onClick={() => handleDelete(u.id)} className="p-1.5 rounded-lg bg-red-100 hover:bg-red-200 text-red-700">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===== TAB: DATABASE ===== */}
{activeTab === 'database' && (
<div className="bg-white rounded-xl shadow p-6 max-w-lg">
<h2 className="font-bold text-lg mb-4">Konfigurasi Database</h2>
{dbMsg.text && (
<div className={`mb-4 px-4 py-2 rounded-lg text-sm ${dbMsg.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{dbMsg.text}
</div>
)}
<div className="space-y-4">
{[
{ label: 'Host', key: 'host', placeholder: '10.10.1.112' },
{ label: 'Port', key: 'port', placeholder: '3306' },
{ label: 'Username DB', key: 'user', placeholder: 'admin' },
{ label: 'Nama Database', key: 'database', placeholder: 'klasifikasi_telur' },
].map(({ label, key, placeholder }) => (
<div key={key}>
<label className="text-sm text-gray-600 mb-1 block">{label}</label>
<input
type="text"
value={(dbConfig as any)[key]}
onChange={e => setDbConfig(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={placeholder}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
))}
{/* Password DB */}
<div>
<label className="text-sm text-gray-600 mb-1 block">Password DB</label>
<div className="relative">
<input
type={showDbPassword ? 'text' : 'password'}
value={dbConfig.password}
onChange={e => setDbConfig(prev => ({ ...prev, password: e.target.value }))}
placeholder="••••••••"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10"
/>
<button
type="button"
onClick={() => setShowDbPassword(p => !p)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
>
{showDbPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={handleTestDb}
disabled={testingDb}
className="flex-1 border border-blue-600 text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{testingDb ? 'Testing...' : 'Test Koneksi'}
</button>
<button
onClick={handleSaveDb}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Simpan
</button>
</div>
<p className="text-xs text-gray-400">
* Perubahan konfigurasi akan update file <code>.env.local</code> dan memerlukan restart server.
</p>
</div>
</div>
)}
</main>
{/* ===== MODAL USER ===== */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg">{editUser ? 'Edit User' : 'Tambah User'}</h3>
<button onClick={() => setShowModal(false)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm text-gray-600 mb-1 block">Nama Lengkap</label>
<input
type="text"
value={userForm.nama}
onChange={e => setUserForm(p => ({ ...p, nama: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
/>
</div>
<div>
<label className="text-sm text-gray-600 mb-1 block">Username</label>
<input
type="text"
value={userForm.username}
onChange={e => setUserForm(p => ({ ...p, username: e.target.value }))}
disabled={!!editUser}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
placeholder="johndoe"
/>
</div>
<div>
<label className="text-sm text-gray-600 mb-1 block">
Password {editUser && <span className="text-gray-400">(kosongkan jika tidak diganti)</span>}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={userForm.password}
onChange={e => setUserForm(p => ({ ...p, password: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(p => !p)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="text-sm text-gray-600 mb-1 block">Role</label>
<select
value={userForm.role}
onChange={e => setUserForm(p => ({ ...p, role: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex gap-3 mt-5">
<button
onClick={() => setShowModal(false)}
className="flex-1 border border-gray-300 text-gray-600 hover:bg-gray-50 px-4 py-2 rounded-lg text-sm"
>
Batal
</button>
<button
onClick={handleSaveUser}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<Check size={16} /> Simpan
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,63 @@
'use client'
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
interface ClassificationPieProps {
data?: any[]
}
const COLORS = ['#16a34a', '#eab308', '#f97316', '#dc2626']
export default function ClassificationPie({ data }: ClassificationPieProps) {
// Default data jika tidak ada data dari props
const defaultData = [
{ name: 'Grade A', value: 124, percentage: '40.5' },
{ name: 'Grade B', value: 98, percentage: '32.0' },
{ name: 'Grade C', value: 56, percentage: '18.3' },
{ name: 'Tidak Layak', value: 22, percentage: '7.2' },
]
const pieData = data && data.length > 0 ? data : defaultData
const totalValue = pieData.reduce((sum, item) => sum + item.value, 0)
return (
<div className="relative">
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={80}
outerRadius={140}
paddingAngle={2}
dataKey="value"
label={({ percentage }) => `${percentage}%`}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none">
<div className="text-3xl font-bold text-gray-800">{totalValue}</div>
<div className="text-xs text-gray-500">Total</div>
</div> */}
{/* Legend */}
<div className="mt-6 grid grid-cols-2 gap-4">
{pieData.map((item, index) => (
<div key={index} className="flex items-center gap-3">
<div className={`w-4 h-4 rounded`} style={{ backgroundColor: COLORS[index] }}></div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-700">{item.name}</div>
<div className="text-xs text-gray-500">{item.percentage}%</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,9 @@
'use client'
export default function EggChart() {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Egg Chart Component</p>
</div>
)
}

View File

@ -0,0 +1,87 @@
'use client'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
interface GrafanaChartProps {
data?: {
time: string
gradeA: number
gradeB: number
gradeC: number
tidakLayak: number
}[]
}
// Fallback data jika API belum ada data
const defaultData = [
{ time: '00:00', gradeA: 0, gradeB: 0, gradeC: 0, tidakLayak: 0 },
]
export default function GrafanaChart({ data }: GrafanaChartProps) {
const chartData = data && data.length > 0 ? data : defaultData
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={chartData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
stroke="#9ca3af"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="#9ca3af"
style={{ fontSize: '12px' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
color: '#fff'
}}
/>
<Legend
wrapperStyle={{
paddingTop: '20px'
}}
/>
<Line
type="monotone"
dataKey="gradeA"
stroke="#16a34a"
strokeWidth={3}
name="Grade A"
dot={{ fill: '#16a34a', r: 4 }}
/>
<Line
type="monotone"
dataKey="gradeB"
stroke="#eab308"
strokeWidth={3}
name="Grade B"
dot={{ fill: '#eab308', r: 4 }}
/>
<Line
type="monotone"
dataKey="gradeC"
stroke="#f97316"
strokeWidth={3}
name="Grade C"
dot={{ fill: '#f97316', r: 4 }}
/>
<Line
type="monotone"
dataKey="tidakLayak"
stroke="#dc2626"
strokeWidth={3}
name="Tidak Layak"
dot={{ fill: '#dc2626', r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
)
}

26
src/lib/db.ts Normal file
View File

@ -0,0 +1,26 @@
import mysql from 'mysql2/promise';
// Buat connection pool
const pool = mysql.createPool({
host: process.env.DB_HOST || '10.10.1.112',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASSWORD || 'Telur@12344321',
database: process.env.DB_NAME || 'klasifikasi_telur',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Function untuk execute query
export async function executeQuery(query: string, params: any[] = []) {
try {
const [results] = await pool.execute(query, params);
return results;
} catch (error) {
console.error('Database query error:', error);
throw error;
}
}
export default pool;

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}