first commit
This commit is contained in:
parent
6ee0066ba2
commit
3aa062465b
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "sortayok",
|
||||
cwd: "D:/sortayok",
|
||||
script: "npm.cmd",
|
||||
args: "start"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
197
src/app/page.tsx
197
src/app/page.tsx
|
|
@ -1,103 +1,100 @@
|
|||
import Image from "next/image";
|
||||
'use client'
|
||||
|
||||
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>
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
<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}
|
||||
/>
|
||||
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}
|
||||
/>
|
||||
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>
|
||||
);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
Loading…
Reference in New Issue