upload pertama
This commit is contained in:
commit
25ba754fca
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
backend/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
backend/silat_monitor.db
|
||||
.env
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.resolve(__dirname, 'silat_monitor.db');
|
||||
const db = new sqlite3.Database(dbPath, (err) => {
|
||||
if (err) console.error('Error opening database:', err);
|
||||
else console.log('Database initialized at', dbPath);
|
||||
});
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS athletes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
height REAL,
|
||||
weight REAL,
|
||||
category TEXT,
|
||||
rfid_tag TEXT UNIQUE
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
athlete_id INTEGER,
|
||||
force REAL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(athlete_id) REFERENCES athletes(id)
|
||||
)
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Inisialisasi admin default jika belum ada
|
||||
db.run(`INSERT OR IGNORE INTO admins (username, password) VALUES ('admin', 'admin123')`);
|
||||
});
|
||||
|
||||
module.exports = db;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"sqlite3": "^5.1.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"socket.io": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require("socket.io");
|
||||
const cors = require('cors');
|
||||
const db = require('./database');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
let currentTestSession = {
|
||||
athleteId: null,
|
||||
attempts: []
|
||||
};
|
||||
|
||||
// Helper for Promisified DB queries
|
||||
const dbAll = (sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const dbGet = (sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const dbRun = (sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// API Routes
|
||||
app.get('/api/athletes', async (req, res) => {
|
||||
try {
|
||||
const athletes = await dbAll('SELECT * FROM athletes ORDER BY id DESC');
|
||||
res.json(athletes);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/athletes/:id', async (req, res) => {
|
||||
try {
|
||||
const athlete = await dbGet('SELECT * FROM athletes WHERE id = ?', [req.params.id]);
|
||||
if (athlete) res.json(athlete);
|
||||
else res.status(404).json({ error: 'Athlete not found' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/athletes', async (req, res) => {
|
||||
const { name, age, height, weight, category, rfid_tag } = req.body;
|
||||
try {
|
||||
const result = await dbRun(
|
||||
'INSERT INTO athletes (name, age, height, weight, category, rfid_tag) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[name, age, height, weight, category, rfid_tag]
|
||||
);
|
||||
res.json({ id: result.id, success: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/athletes/:id', async (req, res) => {
|
||||
try {
|
||||
await dbRun('DELETE FROM attempts WHERE athlete_id = ?', [req.params.id]);
|
||||
await dbRun('DELETE FROM athletes WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/leaderboard', async (req, res) => {
|
||||
try {
|
||||
const query = `
|
||||
SELECT a.name, a.category, MAX(at.force) as best_score
|
||||
FROM athletes a
|
||||
JOIN attempts at ON a.id = at.athlete_id
|
||||
GROUP BY a.id, a.name, a.category
|
||||
ORDER BY best_score DESC
|
||||
`;
|
||||
const leaderboard = await dbAll(query);
|
||||
res.json(leaderboard);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/attempts/:athleteId', async (req, res) => {
|
||||
try {
|
||||
const attempts = await dbAll(
|
||||
'SELECT * FROM attempts WHERE athlete_id = ? ORDER BY timestamp ASC',
|
||||
[req.params.athleteId]
|
||||
);
|
||||
res.json(attempts);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get History for Chart (Option 3)
|
||||
app.get('/api/history/:athleteId', async (req, res) => {
|
||||
try {
|
||||
const history = await dbAll(`
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:%M', timestamp) as date,
|
||||
force
|
||||
FROM attempts
|
||||
WHERE athlete_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`, [req.params.athleteId]);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Start/Stop Test Session logic
|
||||
app.post('/api/start-test', (req, res) => {
|
||||
const { athleteId } = req.body;
|
||||
// Reset session for this athlete
|
||||
currentTestSession = { athleteId: parseInt(athleteId), attempts: [] };
|
||||
io.emit('session_started', { athleteId: currentTestSession.athleteId });
|
||||
console.log(`Session started for athlete ${athleteId}`);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/stop-test', (req, res) => {
|
||||
currentTestSession = { athleteId: null, attempts: [] };
|
||||
io.emit('session_ended');
|
||||
console.log(`Session ended`);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
const admin = await dbGet('SELECT * FROM admins WHERE username = ?', [username]);
|
||||
if (admin && password === admin.password) {
|
||||
res.json({ success: true, token: 'mock-admin-token', username: admin.username });
|
||||
} else {
|
||||
res.status(401).json({ success: false, message: 'Username atau Password salah' });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin Management APIs
|
||||
app.get('/api/admin/users', async (req, res) => {
|
||||
try {
|
||||
const admins = await dbAll('SELECT id, username FROM admins');
|
||||
res.json(admins);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
await dbRun('INSERT INTO admins (username, password) VALUES (?, ?)', [username, password]);
|
||||
res.json({ success: true, message: 'Admin baru ditambahkan' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Username sudah digunakan atau gagal simpan' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', async (req, res) => {
|
||||
try {
|
||||
await dbRun('DELETE FROM admins WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/update-password', async (req, res) => {
|
||||
const { username, oldPassword, newPassword } = req.body;
|
||||
try {
|
||||
const admin = await dbGet('SELECT * FROM admins WHERE username = ?', [username]);
|
||||
if (!admin || oldPassword !== admin.password) {
|
||||
return res.status(400).json({ success: false, message: 'Password lama salah' });
|
||||
}
|
||||
await dbRun('UPDATE admins SET password = ? WHERE username = ?', [newPassword, username]);
|
||||
res.json({ success: true, message: 'Password berhasil diubah' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Gagal mengubah password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.io
|
||||
io.on('connection', (socket) => {
|
||||
console.log('Client connected', socket.id);
|
||||
|
||||
// Received from RFID reader (simulation)
|
||||
socket.on('rfid_scan', (tagId) => {
|
||||
console.log('RFID Scanned:', tagId);
|
||||
io.emit('rfid_scanned', tagId);
|
||||
});
|
||||
|
||||
// Received from Impact Sensor (simulation)
|
||||
// Stream for graph visualization
|
||||
socket.on('sensor_stream', (value) => {
|
||||
io.emit('sensor_live_data', value);
|
||||
});
|
||||
|
||||
// Impact detected (Peak value or significant hit)
|
||||
socket.on('impact_detected', async (forceValue) => {
|
||||
console.log('Impact detected:', forceValue);
|
||||
|
||||
if (currentTestSession.athleteId) {
|
||||
const athleteId = currentTestSession.athleteId;
|
||||
|
||||
// Save to DB
|
||||
try {
|
||||
await dbRun('INSERT INTO attempts (athlete_id, force) VALUES (?, ?)', [athleteId, forceValue]);
|
||||
|
||||
currentTestSession.attempts.push(forceValue);
|
||||
const attemptCount = currentTestSession.attempts.length;
|
||||
|
||||
io.emit('new_attempt_result', {
|
||||
force: forceValue,
|
||||
attemptNumber: attemptCount,
|
||||
athleteId
|
||||
});
|
||||
|
||||
if (attemptCount >= 3) {
|
||||
io.emit('test_milestone_reached', { athleteId, count: 3 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error saving attempt:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = 3000;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
const io = require('socket.io-client');
|
||||
|
||||
const socket = io('http://localhost:3000');
|
||||
|
||||
const tagId = process.argv[2] || 'TAG-' + Math.floor(Math.random() * 100000);
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server as RFID Reader');
|
||||
console.log(`Scanning tag: ${tagId}`);
|
||||
socket.emit('rfid_scan', tagId);
|
||||
|
||||
setTimeout(() => {
|
||||
socket.disconnect();
|
||||
}, 1000);
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
const io = require('socket.io-client');
|
||||
|
||||
const socket = io('http://localhost:3000');
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server as Impact Sensor');
|
||||
simulatePunch();
|
||||
});
|
||||
|
||||
function simulatePunch() {
|
||||
console.log('Simulating punch...');
|
||||
|
||||
// 1. Send low noise (idle)
|
||||
let steps = 0;
|
||||
const idleInterval = setInterval(() => {
|
||||
const noise = Math.random() * 5;
|
||||
socket.emit('sensor_stream', noise);
|
||||
steps++;
|
||||
|
||||
if (steps > 20) {
|
||||
clearInterval(idleInterval);
|
||||
sendImpact();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function sendImpact() {
|
||||
// 2. Send spike (Impact)
|
||||
let force = 0;
|
||||
const peak = 500 + Math.random() * 500; // Random peak between 500 and 1000
|
||||
let up = true;
|
||||
|
||||
const impactInterval = setInterval(() => {
|
||||
if (up) {
|
||||
force += 100;
|
||||
if (force >= peak) {
|
||||
force = peak;
|
||||
up = false;
|
||||
// Emit event at peak
|
||||
socket.emit('impact_detected', Math.round(peak));
|
||||
}
|
||||
} else {
|
||||
force -= 100;
|
||||
if (force <= 0) {
|
||||
force = 0;
|
||||
clearInterval(impactInterval);
|
||||
socket.emit('sensor_stream', 0);
|
||||
console.log(`Punch finished. Peak: ${Math.round(peak)}`);
|
||||
socket.disconnect(); // Or keep running for next punch
|
||||
}
|
||||
}
|
||||
socket.emit('sensor_stream', force);
|
||||
}, 20); // Fast update for impact
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
const { initializeApp } = require('./node_modules/firebase/app');
|
||||
const { getDatabase, ref, get, remove, set } = require('./node_modules/firebase/database');
|
||||
const sqlite3 = require('./backend/node_modules/sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyDhFtqvaTMgYH9vyAHFclDUXiOxkl-2GqM",
|
||||
authDomain: "silat-b3100.firebaseapp.com",
|
||||
databaseURL: "https://silat-b3100-default-rtdb.firebaseio.com/",
|
||||
projectId: "silat-b3100",
|
||||
storageBucket: "silat-b3100.firebasestorage.app",
|
||||
messagingSenderId: "471266034969",
|
||||
appId: "1:471266034969:web:467de19582cf2fd3da3188"
|
||||
};
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const rtdb = getDatabase(app);
|
||||
|
||||
const todayStart = 1778691600000; // 14/05/2026 00:00:00 UTC+7
|
||||
const galihIds = ['026AB155', '23B29603'];
|
||||
|
||||
async function cleanupFirebase() {
|
||||
console.log("--- CLEANING FIREBASE ---");
|
||||
try {
|
||||
const usersSnap = await get(ref(rtdb, 'users'));
|
||||
const users = usersSnap.val() || {};
|
||||
const activeUserIds = Object.keys(users);
|
||||
|
||||
const nodes = ['test_history', 'attempts'];
|
||||
|
||||
for (const node of nodes) {
|
||||
console.log(`Checking node: ${node}`);
|
||||
const nodeSnap = await get(ref(rtdb, node));
|
||||
const data = nodeSnap.val() || {};
|
||||
|
||||
for (const id in data) {
|
||||
const isGalih = galihIds.includes(id);
|
||||
const isOrphan = !activeUserIds.includes(id);
|
||||
|
||||
if (isGalih || isOrphan) {
|
||||
console.log(`Processing ID: ${id} (${isGalih ? 'Galih' : 'Orphan'})`);
|
||||
const sessions = data[id];
|
||||
|
||||
for (const sessionKey in sessions) {
|
||||
let sessionTs = 0;
|
||||
if (sessionKey.startsWith('session_')) {
|
||||
sessionTs = parseInt(sessionKey.replace('session_', ''));
|
||||
} else if (sessions[sessionKey].timestamp) {
|
||||
sessionTs = sessions[sessionKey].timestamp;
|
||||
}
|
||||
|
||||
// Jika timestamp di bawah hari ini, hapus
|
||||
if (sessionTs < todayStart) {
|
||||
console.log(` Deleting old session: ${sessionKey} (TS: ${sessionTs})`);
|
||||
await remove(ref(rtdb, `${node}/${id}/${sessionKey}`));
|
||||
} else {
|
||||
console.log(` KEEPING today's session: ${sessionKey} (TS: ${sessionTs})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("Firebase cleanup done.");
|
||||
} catch (err) {
|
||||
console.error("Firebase Error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSQLite() {
|
||||
console.log("\n--- CLEANING SQLITE ---");
|
||||
const dbPath = path.resolve(__dirname, 'backend', 'silat_monitor.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
db.serialize(() => {
|
||||
// Hapus atlet yang tidak ada di list user baru (yang TEST- ID nya tidak valid lagi)
|
||||
// Dan hapus attempts lama
|
||||
|
||||
// Cari ID atlet yang namanya Fani/Ferdi yang lama
|
||||
db.run("DELETE FROM attempts WHERE timestamp < datetime(1778691600/1000, 'unixepoch')", (err) => {
|
||||
if (err) console.error("Error deleting old attempts:", err);
|
||||
else console.log("Deleted old attempts in SQLite.");
|
||||
});
|
||||
|
||||
// Hapus atlet yang RFID nya diawali TEST- (Data simulasi lama)
|
||||
db.run("DELETE FROM athletes WHERE rfid_tag LIKE 'TEST-%'", (err) => {
|
||||
if (err) console.error("Error deleting old athletes:", err);
|
||||
else console.log("Deleted old athletes (TEST-) in SQLite.");
|
||||
});
|
||||
});
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanupFirebase();
|
||||
cleanupSQLite();
|
||||
console.log("\n✅ ALL CLEANUP TASKS COMPLETED.");
|
||||
process.exit();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
#include "HX711.h"
|
||||
|
||||
// PIN HX711 ke ESP32
|
||||
#define DT_PIN 32
|
||||
#define SCK_PIN 33
|
||||
|
||||
HX711 scale;
|
||||
|
||||
// ⚠️ Mode Kalibrasi Multi-Titik (Piecewise Linear)
|
||||
// Rumus tunggal diganti dengan pemetaan khusus agar 1.1, 1.8, dan 3.65 akurat semua.
|
||||
|
||||
// Batas nol (anti noise)
|
||||
float zero_threshold = 0.2;
|
||||
|
||||
// Fungsi Kalibrasi Multi-Titik untuk menyelaraskan semua beban
|
||||
float hitung_berat_akurat(float raw) {
|
||||
float r0 = 0.0, w0 = 0.0;
|
||||
float r1 = 20185.0, w1 = 1.1;
|
||||
float r2 = 30154.0, w2 = 1.8;
|
||||
float r3 = 62972.0, w3 = 3.65;
|
||||
|
||||
if (raw <= r0) return 0.0;
|
||||
if (raw <= r1) return w0 + (raw - r0) * (w1 - w0) / (r1 - r0);
|
||||
if (raw <= r2) return w1 + (raw - r1) * (w2 - w1) / (r2 - r1);
|
||||
if (raw <= r3) return w2 + (raw - r2) * (w3 - w2) / (r3 - r2);
|
||||
|
||||
// Ekstrapolasi untuk beban lebih dari 3.65kg (Pukulan)
|
||||
return w3 + (raw - r3) * (w3 - w2) / (r3 - r2);
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
|
||||
scale.begin(DT_PIN, SCK_PIN);
|
||||
|
||||
Serial.println("=== LOADCELL START ===");
|
||||
Serial.println("Pastikan TIDAK ADA BEBAN di timbangan");
|
||||
delay(3000);
|
||||
|
||||
// Set kalibrasi ke 1 karena kita menghitung manual berdasarkan nilai mentah (raw)
|
||||
scale.set_scale(1.0);
|
||||
|
||||
// Jadikan posisi sekarang = 0
|
||||
scale.tare();
|
||||
|
||||
Serial.println("Timbangan SIAP!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Gunakan 1 kali bacaan nilai mentah (raw value) tanpa jeda
|
||||
float raw = scale.get_value(1);
|
||||
|
||||
// Hitung berat menggunakan pemetaan multi-titik
|
||||
float berat = hitung_berat_akurat(raw);
|
||||
|
||||
// =========================
|
||||
// FILTER AGAR 0 BERSIH
|
||||
// =========================
|
||||
// Jika sering tidak kembali ke 0 (nyangkut di 0.3 atau 0.4), naikkan zero_threshold di atas!
|
||||
if (abs(berat) < zero_threshold) {
|
||||
berat = 0;
|
||||
} else if (berat < 0) {
|
||||
berat = 0; // Abaikan kalau minus
|
||||
}
|
||||
|
||||
// =========================
|
||||
// TAMPILKAN HASIL
|
||||
// =========================
|
||||
Serial.print("Berat: ");
|
||||
Serial.print(berat, 2);
|
||||
Serial.println(" kg");
|
||||
|
||||
delay(100); // Dipercepat agar kembalinya ke 0 terlihat seketika
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
#include "HX711.h"
|
||||
|
||||
// Pin disesuaikan dengan ESP32 Anda di impact_monitor.ino
|
||||
#define DT_PIN 32
|
||||
#define SCK_PIN 33
|
||||
|
||||
HX711 scale;
|
||||
|
||||
// Silakan sesuaikan nilai ini untuk load cell 50kg saat melakukan kalibrasi
|
||||
float calibration_factor = 103.8;
|
||||
float threshold = 2.0;
|
||||
|
||||
unsigned long emptyStartTime = 0;
|
||||
bool isEmpty = false;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
|
||||
scale.begin(DT_PIN, SCK_PIN);
|
||||
|
||||
Serial.println("Jangan ada beban...");
|
||||
delay(3000);
|
||||
|
||||
scale.set_scale(calibration_factor);
|
||||
scale.tare();
|
||||
|
||||
Serial.println("Timbangan siap!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
float berat = scale.get_units(20);
|
||||
|
||||
// === DETEKSI KONDISI KOSONG ===
|
||||
if (abs(berat) < threshold) {
|
||||
|
||||
if (!isEmpty) {
|
||||
// baru masuk kondisi kosong
|
||||
emptyStartTime = millis();
|
||||
isEmpty = true;
|
||||
}
|
||||
|
||||
// kalau sudah kosong selama 2 detik → tare
|
||||
if (millis() - emptyStartTime > 2000) {
|
||||
scale.tare();
|
||||
berat = 0;
|
||||
}
|
||||
|
||||
} else {
|
||||
// ada beban → reset status kosong
|
||||
isEmpty = false;
|
||||
}
|
||||
|
||||
float berat_kg = berat / 1000.0;
|
||||
|
||||
Serial.print("Berat: ");
|
||||
Serial.print(berat_kg, 1);
|
||||
Serial.println(" kg");
|
||||
|
||||
delay(300);
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
#include <SPI.h>
|
||||
#include <MFRC522.h>
|
||||
#include "HX711.h"
|
||||
#include <WiFi.h>
|
||||
#include <Firebase_ESP_Client.h>
|
||||
#include "time.h"
|
||||
|
||||
// ================= WIFI =================
|
||||
#define WIFI_SSID "MalaikatIzrail"
|
||||
#define WIFI_PASSWORD "12345678910"
|
||||
|
||||
// ================= FIREBASE =================
|
||||
#define API_KEY "AIzaSyDhFtqvaTMgYH9vyAHFclDUXiOxkl-2GqM"
|
||||
#define DATABASE_URL "https://silat-b3100-default-rtdb.firebaseio.com/"
|
||||
|
||||
#define USER_EMAIL "faniferdiyanto1980@gmail.com"
|
||||
#define USER_PASSWORD "faniferdiyanto80"
|
||||
|
||||
// ================= PIN =================
|
||||
#define SS_PIN 21
|
||||
#define RST_PIN 22
|
||||
#define DT_PIN 32
|
||||
#define SCK_PIN 33
|
||||
|
||||
MFRC522 rfid(SS_PIN, RST_PIN);
|
||||
HX711 scale;
|
||||
|
||||
// ================= FIREBASE =================
|
||||
FirebaseData fbdo;
|
||||
FirebaseData fbdoStream; // Dedicated stream object for test_command
|
||||
FirebaseAuth auth;
|
||||
FirebaseConfig config;
|
||||
bool firebaseReady = false;
|
||||
|
||||
// ================= VARIABEL =================
|
||||
float calibration_factor = 17740.0;
|
||||
float offset_manual = 0.10;
|
||||
float alpha = 0.95; // Ditingkatkan agar merespon lebih cepat / instan
|
||||
float threshold = 2.5; // PERBAIKAN: Dinaikkan ke 2.5 KG agar lebih aman dari noise dan pergeseran mekanis
|
||||
float smoothedWeight = 0;
|
||||
float peakValue = 0;
|
||||
|
||||
// Test variables
|
||||
String currentTestUID = "";
|
||||
int currentAttempt = 1;
|
||||
String currentStrikeType = "pukulan"; // Default
|
||||
bool isTesting = false;
|
||||
unsigned long testStartTime = 0;
|
||||
unsigned long firstHitTime = 0; // Waktu saat pukulan pertama dideteksi
|
||||
String handledCommand = ""; // Menghindari pengulangan perintah yang sama
|
||||
const unsigned long TEST_DURATION = 5000;
|
||||
|
||||
float graphData[100];
|
||||
int graphIndex = 0;
|
||||
|
||||
// Timer Optimasi
|
||||
unsigned long lastFirebaseUpdate = 0;
|
||||
const unsigned long FIREBASE_UPDATE_INTERVAL = 300; // Lebih cepat: 100ms
|
||||
unsigned long lastSampleTime = 0;
|
||||
const unsigned long SAMPLING_INTERVAL = 30; // Lebih cepat: 30ms
|
||||
|
||||
// PERBAIKAN: Throttle checkTestCommand agar tidak blocking setiap loop
|
||||
unsigned long lastCommandCheck = 0;
|
||||
const unsigned long COMMAND_CHECK_INTERVAL = 500; // Cek perintah setiap 500ms saja
|
||||
|
||||
// ================= DATA LOGGING =================
|
||||
String getUID() {
|
||||
String uid = "";
|
||||
for (byte i = 0; i < rfid.uid.size; i++) {
|
||||
if (rfid.uid.uidByte[i] < 0x10) uid += "0";
|
||||
uid += String(rfid.uid.uidByte[i], HEX);
|
||||
}
|
||||
uid.toUpperCase();
|
||||
return uid;
|
||||
}
|
||||
|
||||
void syncTime() {
|
||||
configTime(0, 0, "pool.ntp.org", "time.nist.gov", "time.google.com");
|
||||
Serial.print("Sync time");
|
||||
time_t now = time(nullptr);
|
||||
int attempts = 0;
|
||||
while (now < 100000 && attempts < 40) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
now = time(nullptr);
|
||||
attempts++;
|
||||
}
|
||||
if (now >= 100000) Serial.println("\n✅ Time synchronized");
|
||||
else Serial.println("\n⚠️ Time sync failed");
|
||||
}
|
||||
|
||||
void initFirebase() {
|
||||
config.api_key = API_KEY;
|
||||
config.database_url = DATABASE_URL;
|
||||
auth.user.email = USER_EMAIL;
|
||||
auth.user.password = USER_PASSWORD;
|
||||
config.timeout.serverResponse = 10000;
|
||||
|
||||
// PERBAIKAN: Memperbesar buffer TX menjadi 4096 byte agar mampu menampung paket JSON grafik sekaligus!
|
||||
fbdo.setBSSLBufferSize(4096, 4096);
|
||||
fbdo.setResponseSize(2048);
|
||||
fbdoStream.setBSSLBufferSize(2048, 512);
|
||||
|
||||
Firebase.begin(&config, &auth);
|
||||
Firebase.reconnectWiFi(true);
|
||||
Serial.println("🔥 Firebase Initialized");
|
||||
}
|
||||
|
||||
void updateSensorStatus() {
|
||||
if (firebaseReady) {
|
||||
// Tulis Unix timestamp agar dashboard bisa deteksi kapan terakhir aktif
|
||||
time_t now = time(nullptr);
|
||||
Firebase.RTDB.setString(&fbdo, "/system/sensor_status", "Online");
|
||||
Firebase.RTDB.setInt(&fbdo, "/system/last_seen", (int)now);
|
||||
}
|
||||
}
|
||||
|
||||
void handleRFIDScan() {
|
||||
if (!rfid.PICC_IsNewCardPresent() || !rfid.PICC_ReadCardSerial()) return;
|
||||
String uid = getUID();
|
||||
Serial.println("RFID detected: " + uid);
|
||||
Firebase.RTDB.setString(&fbdo, "/system/last_scanned", uid);
|
||||
delay(1000);
|
||||
rfid.PICC_HaltA();
|
||||
rfid.PCD_StopCrypto1();
|
||||
}
|
||||
|
||||
float kgToNewton(float kg) { return kg * 9.81; }
|
||||
|
||||
void checkTestCommand() {
|
||||
if (isTesting) return;
|
||||
|
||||
if (millis() - lastCommandCheck < COMMAND_CHECK_INTERVAL) return;
|
||||
lastCommandCheck = millis();
|
||||
|
||||
if (Firebase.RTDB.get(&fbdoStream, "/test_command")) {
|
||||
String command = fbdoStream.stringData();
|
||||
|
||||
// PERBAIKAN: Hanya jalan jika perintah baru (cegah loop otomatis)
|
||||
if (command != "" && command != "null" && command != handledCommand && command.indexOf('|') > 0) {
|
||||
handledCommand = command; // Tandai perintah sudah ditangani
|
||||
|
||||
int firstSep = command.indexOf('|');
|
||||
int secondSep = command.indexOf('|', firstSep + 1);
|
||||
|
||||
currentTestUID = command.substring(0, firstSep);
|
||||
|
||||
if (secondSep > 0) {
|
||||
currentAttempt = command.substring(firstSep + 1, secondSep).toInt();
|
||||
currentStrikeType = command.substring(secondSep + 1);
|
||||
} else {
|
||||
currentAttempt = command.substring(firstSep + 1).toInt();
|
||||
currentStrikeType = "pukulan"; // Fallback
|
||||
}
|
||||
|
||||
Serial.println("--- TEST STARTED ---");
|
||||
Serial.print("UID: "); Serial.println(currentTestUID);
|
||||
Serial.print("Attempt: "); Serial.println(currentAttempt);
|
||||
Serial.print("Type: "); Serial.println(currentStrikeType);
|
||||
|
||||
isTesting = true;
|
||||
testStartTime = millis();
|
||||
firstHitTime = 0;
|
||||
peakValue = 0;
|
||||
smoothedWeight = 0;
|
||||
graphIndex = 0;
|
||||
memset(graphData, 0, sizeof(graphData));
|
||||
|
||||
Firebase.RTDB.setFloat(&fbdo, "/realtime/current_force", 0);
|
||||
Firebase.RTDB.setFloat(&fbdo, "/realtime/peak_force", 0);
|
||||
Firebase.RTDB.setString(&fbdo, "/system/test_status", "measuring");
|
||||
Firebase.RTDB.setString(&fbdo, "/test_command", ""); // Bersihkan perintah
|
||||
|
||||
// PERBAIKAN: Reset angka di layar dan nol-kan sensor tepat sebelum mulai
|
||||
FirebaseJson rtReset;
|
||||
rtReset.set("current_force", 0);
|
||||
rtReset.set("peak_force", 0);
|
||||
Firebase.RTDB.updateNode(&fbdo, "/realtime", &rtReset);
|
||||
|
||||
scale.tare(); // Nol-kan sensor untuk menghilangkan drift dari pukulan sebelumnya
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sendRealtimeData() {
|
||||
if (!isTesting || !firebaseReady) return;
|
||||
|
||||
if (millis() - lastFirebaseUpdate > FIREBASE_UPDATE_INTERVAL) {
|
||||
lastFirebaseUpdate = millis();
|
||||
|
||||
float newton = kgToNewton(smoothedWeight);
|
||||
float peakNewton = kgToNewton(peakValue);
|
||||
|
||||
// MENGGUNAKAN Async DENGAN UPDATE NODE UNTUK MENGURANGI SPAM (Mencegah SSL Crash)
|
||||
FirebaseJson rtJson;
|
||||
rtJson.set("current_force", newton);
|
||||
rtJson.set("peak_force", peakNewton);
|
||||
Firebase.RTDB.updateNodeAsync(&fbdo, "/realtime", &rtJson);
|
||||
|
||||
// PENGIRIMAN GRAFIK REALTIME DIHAPUS:
|
||||
// Mengirim titik grafik satu-per-satu setiap 300ms ke Firebase membebani memori SSL (menyebabkan Error mRunUntil).
|
||||
// Grafik akan langsung dikirim sekaligus dalam format JSON pada saat SaveResult (jauh lebih cepat & aman).
|
||||
}
|
||||
}
|
||||
|
||||
void saveTestResult() {
|
||||
if (!firebaseReady) return;
|
||||
float peakNewton = kgToNewton(peakValue);
|
||||
|
||||
// Jika diberhentikan manual / tidak ada benturan
|
||||
if (peakValue <= threshold) {
|
||||
Serial.println("⚠️ No impact detected, skipping save.");
|
||||
// Gunakan JSON agar reset dikirim dalam 1x request cepat (mencegah error SSL)
|
||||
FirebaseJson resetJson;
|
||||
resetJson.set("realtime/current_force", 0);
|
||||
resetJson.set("realtime/peak_force", 0);
|
||||
resetJson.set("system/test_status", "idle");
|
||||
resetJson.set("test_command", "");
|
||||
|
||||
// PERBAIKAN: Hanya bersihkan memori Anti-Spam JIKA perintah reset berhasil dikirim ke Firebase!
|
||||
// Ini mencegah "tombol mulai ngeklik sendiri" jika internet sempat putus.
|
||||
if (Firebase.RTDB.updateNode(&fbdo, "/", &resetJson)) {
|
||||
handledCommand = "";
|
||||
} else {
|
||||
Serial.println("❌ Gagal mereset status, mencegah infinite loop.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("--- SAVING RESULT ---");
|
||||
String historyPath = "/test_history/" + currentTestUID + "/attempt_" + String(currentAttempt);
|
||||
|
||||
// 1. Gabungkan semua data histori dalam 1 Paket JSON!
|
||||
FirebaseJson historyJson;
|
||||
historyJson.set("peak_kg", peakValue);
|
||||
historyJson.set("peak_newton", peakNewton);
|
||||
historyJson.set("type", currentStrikeType);
|
||||
historyJson.set("timestamp", millis());
|
||||
|
||||
FirebaseJson graphJson;
|
||||
for (int i = 0; i < graphIndex && i < 100; i++) {
|
||||
graphJson.set(String(i), graphData[i]);
|
||||
}
|
||||
historyJson.set("graph", graphJson);
|
||||
Firebase.RTDB.setJSON(&fbdo, historyPath, &historyJson);
|
||||
|
||||
// 2. Tampilkan hasil akhir di layar besar (Persistence) ditangani oleh Website
|
||||
// menggunakan data dari histori di atas, sehingga kita tidak perlu kirim ulang ke realtime/current_force
|
||||
// hal ini mencegah munculnya "puncak ganda" di grafik.
|
||||
|
||||
// 3. Reset Status Sistem
|
||||
FirebaseJson statusReset;
|
||||
statusReset.set("system/test_status", "idle");
|
||||
statusReset.set("test_command", "");
|
||||
|
||||
if (Firebase.RTDB.updateNode(&fbdo, "/", &statusReset)) {
|
||||
handledCommand = "";
|
||||
}
|
||||
Serial.println("✅ Result Saved!");
|
||||
}
|
||||
|
||||
void readLoadcell() {
|
||||
if (!scale.is_ready()) return;
|
||||
|
||||
float raw = scale.get_units(1);
|
||||
raw = raw + offset_manual; // Tambahkan hasil offset kalibrasi Anda
|
||||
|
||||
if (raw < 0) raw = 0; // Abaikan minus
|
||||
|
||||
// PERBAIKAN: Potong semua getaran ringan (di bawah 1.5 KG) menjadi 0.
|
||||
// Ini mencegah munculnya angka "1" atau "2" Newton di layar akibat noise sensor!
|
||||
if (raw < threshold) {
|
||||
raw = 0;
|
||||
}
|
||||
|
||||
// KUNCI RESPONSIVITAS: Gunakan data mentah (raw) untuk mendeteksi puncak, bukan data yang sudah dihaluskan!
|
||||
if (raw > peakValue) peakValue = raw;
|
||||
|
||||
smoothedWeight = (alpha * raw) + ((1 - alpha) * smoothedWeight);
|
||||
if (smoothedWeight < threshold) smoothedWeight = 0;
|
||||
|
||||
if (isTesting && millis() - lastSampleTime > SAMPLING_INTERVAL) {
|
||||
lastSampleTime = millis();
|
||||
if (graphIndex < 100) {
|
||||
graphData[graphIndex] = kgToNewton(raw); // Gunakan raw juga agar grafik tidak terpotong (halus akan menahan data sejenak)
|
||||
graphIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTesting) {
|
||||
sendRealtimeData();
|
||||
|
||||
// 1. Deteksi Pukulan Pertama (Auto-Stop Trigger)
|
||||
if (raw > threshold && firstHitTime == 0) {
|
||||
firstHitTime = millis();
|
||||
Serial.println("🎯 Impact detected! Auto-stop in 3 seconds...");
|
||||
}
|
||||
|
||||
// 2. Cek Auto-Stop (3 detik setelah pukulan)
|
||||
if (firstHitTime > 0 && (millis() - firstHitTime > 3000)) {
|
||||
Serial.println("⏹️ Auto-stop triggered");
|
||||
isTesting = false;
|
||||
saveTestResult();
|
||||
firstHitTime = 0;
|
||||
}
|
||||
// 3. PERBAIKAN: Ganti pengecekan tombol Stop manual dengan Timeout otomatis (30 detik)
|
||||
// Ini menghilangkan proses "blocking" saat mengambil data dari Firebase yang bikin Pukulan Delay!
|
||||
else if (firstHitTime == 0 && (millis() - testStartTime > 30000)) {
|
||||
Serial.println("⏳ Timeout, no impact detected.");
|
||||
isTesting = false;
|
||||
saveTestResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
|
||||
|
||||
syncTime();
|
||||
initFirebase();
|
||||
while (!Firebase.ready()) delay(100);
|
||||
firebaseReady = true;
|
||||
|
||||
Firebase.RTDB.setString(&fbdo, "/system/test_status", "idle");
|
||||
Firebase.RTDB.setString(&fbdo, "/test_command", "");
|
||||
|
||||
SPI.begin();
|
||||
rfid.PCD_Init();
|
||||
|
||||
// PERBAIKAN: Kekuatan antena RFID
|
||||
// Disetel ke 38dB sesuai hasil pengetesan. Level ini paling stabil dan responsif
|
||||
// untuk menembus kotak plastik 2mm tanpa membuat modul RC522 crash/blank.
|
||||
rfid.PCD_SetAntennaGain(rfid.RxGain_38dB);
|
||||
|
||||
scale.begin(DT_PIN, SCK_PIN);
|
||||
delay(2000);
|
||||
|
||||
// Set kalibrasi dengan angka yang sudah fix
|
||||
scale.set_scale(calibration_factor);
|
||||
scale.tare();
|
||||
|
||||
Serial.println("🚀 SYSTEM READY");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (Firebase.ready()) {
|
||||
readLoadcell();
|
||||
checkTestCommand();
|
||||
|
||||
static unsigned long lastStatusUpdate = 0;
|
||||
if (millis() - lastStatusUpdate > 10000) {
|
||||
lastStatusUpdate = millis();
|
||||
updateSensorStatus();
|
||||
}
|
||||
|
||||
if (!isTesting) {
|
||||
handleRFIDScan();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#include <SPI.h>
|
||||
#include <MFRC522.h>
|
||||
|
||||
#define SS_PIN 21
|
||||
#define RST_PIN 22
|
||||
|
||||
MFRC522 rfid(SS_PIN, RST_PIN);
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
SPI.begin();
|
||||
rfid.PCD_Init();
|
||||
|
||||
Serial.println("\n\n=========================================");
|
||||
Serial.println(" TEST RESPONSIVITAS & GAIN ANTENA RFID ");
|
||||
Serial.println("=========================================");
|
||||
Serial.println("Cara Pakai: Dekatkan kartu dari jarak jauh ke dekat.");
|
||||
Serial.println("Ketik angka (0-5) di Serial Monitor lalu Enter untuk mengubah Gain:");
|
||||
Serial.println("0 = 18 dB (Paling Lemah)");
|
||||
Serial.println("1 = 23 dB");
|
||||
Serial.println("2 = 33 dB (Default)");
|
||||
Serial.println("3 = 38 dB");
|
||||
Serial.println("4 = 43 dB");
|
||||
Serial.println("5 = 48 dB (Maksimal)");
|
||||
Serial.println("=========================================\n");
|
||||
|
||||
rfid.PCD_SetAntennaGain(rfid.RxGain_33dB);
|
||||
Serial.println("-> Mode Awal Aktif: 2 (33 dB - Default)");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Cek input angka dari Serial Monitor
|
||||
if (Serial.available() > 0) {
|
||||
char inChar = Serial.read();
|
||||
if (inChar == '0') { rfid.PCD_SetAntennaGain(rfid.RxGain_18dB); Serial.println("\n>> Gain diubah ke 0 (18 dB)"); }
|
||||
else if (inChar == '1') { rfid.PCD_SetAntennaGain(rfid.RxGain_23dB); Serial.println("\n>> Gain diubah ke 1 (23 dB)"); }
|
||||
else if (inChar == '2') { rfid.PCD_SetAntennaGain(rfid.RxGain_33dB); Serial.println("\n>> Gain diubah ke 2 (33 dB - Default)"); }
|
||||
else if (inChar == '3') { rfid.PCD_SetAntennaGain(rfid.RxGain_38dB); Serial.println("\n>> Gain diubah ke 3 (38 dB)"); }
|
||||
else if (inChar == '4') { rfid.PCD_SetAntennaGain(rfid.RxGain_43dB); Serial.println("\n>> Gain diubah ke 4 (43 dB)"); }
|
||||
else if (inChar == '5') { rfid.PCD_SetAntennaGain(rfid.RxGain_max); Serial.println("\n>> Gain diubah ke 5 (48 dB - Maksimal)"); }
|
||||
}
|
||||
|
||||
// Cek RFID (Tanpa ada delay atau WiFi yang menghambat)
|
||||
if (rfid.PICC_IsNewCardPresent() && rfid.PICC_ReadCardSerial()) {
|
||||
Serial.print("💳 Kartu Terdeteksi! UID: ");
|
||||
for (byte i = 0; i < rfid.uid.size; i++) {
|
||||
if (rfid.uid.uidByte[i] < 0x10) Serial.print("0");
|
||||
Serial.print(rfid.uid.uidByte[i], HEX);
|
||||
}
|
||||
Serial.println(" (Responsif & Lancar!)");
|
||||
|
||||
// Jeda sedikit agar layar tidak terlalu penuh teks saat kartu ditempel
|
||||
delay(200);
|
||||
|
||||
rfid.PICC_HaltA();
|
||||
rfid.PCD_StopCrypto1();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
Binary file not shown.
|
|
@ -0,0 +1,29 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<title>ImpactMonitor - Atlet IoT</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"firebase": "^12.11.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jspdf": "^4.2.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,42 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
import AdminLogin from './pages/AdminLogin';
|
||||
import Register from './pages/Register';
|
||||
import Monitor from './pages/Monitor';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Leaderboard from './pages/Leaderboard';
|
||||
import Settings from './pages/Settings';
|
||||
import LiveTest from './pages/LiveTest';
|
||||
import { Activity, UserPlus, Trophy, LayoutDashboard, Monitor as MonitorIcon, LogOut, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { cn } from './lib/utils';
|
||||
import { Button } from './components/ui/button';
|
||||
|
||||
const NavLink = ({ to, icon: Icon, label }) => {
|
||||
const { pathname } = useLocation();
|
||||
const isActive = pathname === to || (to !== '/' && pathname.startsWith(to));
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 text-sm font-semibold transition-all px-4 py-2 rounded-xl group",
|
||||
isActive
|
||||
? "text-primary bg-primary/8 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className={cn("transition-transform group-hover:scale-110", isActive && "text-primary")} />
|
||||
<span>{label}</span>
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminLayout = () => {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col tech-grid">
|
||||
<nav className="sticky top-0 z-50 w-full border-b border-black/5 bg-white/60 backdrop-blur-2xl">
|
||||
<div className="container mx-auto px-4 h-20 flex justify-between items-center">
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
<img src="/logo.png" alt="IKSPI" className="h-12 w-auto object-contain group-hover:scale-105 transition-transform drop-shadow-sm" />
|
||||
<div className="h-8 w-px bg-slate-200 mx-1 hidden sm:block" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-bold tracking-tighter leading-none text-slate-900">
|
||||
IMPACT<span className="text-primary">MONITOR</span>
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-black text-slate-400">Pro Edition</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-2 p-1.5 bg-slate-100/50 rounded-2xl border border-black/5">
|
||||
<NavLink to="/" icon={LayoutDashboard} label="Dashboard" />
|
||||
<NavLink to="/register" icon={UserPlus} label="Register" />
|
||||
<NavLink to="/monitor" icon={MonitorIcon} label="Monitor" />
|
||||
<NavLink to="/leaderboard" icon={Trophy} label="Leaderboard" />
|
||||
<NavLink to="/settings" icon={SettingsIcon} label="Settings" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-[1px] bg-slate-200 hidden sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-slate-500 hover:text-red-500 rounded-full"
|
||||
title="Logout"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="flex-1 container mx-auto p-6 md:p-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<footer className="py-8 border-t border-black/5 text-center text-sm text-slate-400">
|
||||
<p>© {new Date().getFullYear()} ImpactMonitor Pro · Dirancang untuk Atlet Profesional</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<AdminLogin />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/monitor" element={<Monitor />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/live-test/:id" element={<LiveTest />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
const API_URL = import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api` : 'http://localhost:3000/api';
|
||||
|
||||
export const getAthletes = async () => {
|
||||
const res = await fetch(`${API_URL}/athletes`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const getAthlete = async (id) => {
|
||||
const res = await fetch(`${API_URL}/athletes/${id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch athlete');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const registerAthlete = async (data) => {
|
||||
const res = await fetch(`${API_URL}/athletes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Registration failed');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const getLeaderboard = async () => {
|
||||
const res = await fetch(`${API_URL}/leaderboard`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const startTest = async (athleteId) => {
|
||||
const res = await fetch(`${API_URL}/start-test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ athleteId }),
|
||||
});
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const stopTest = async () => {
|
||||
const res = await fetch(`${API_URL}/stop-test`, { method: 'POST' });
|
||||
return res.json();
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,232 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { rtdb } from '../firebase';
|
||||
import { ref, get } from 'firebase/database';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TrendingUp, Calendar, Zap, Target } from 'lucide-react';
|
||||
|
||||
const HistoryModal = ({ athlete, isOpen, onClose }) => {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [debugKeys, setDebugKeys] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !athlete) return;
|
||||
|
||||
setLoading(true);
|
||||
setDebugKeys(["Mencari..."]);
|
||||
|
||||
const possibleIds = new Set([
|
||||
athlete.id,
|
||||
athlete.id?.toLowerCase(),
|
||||
athlete.id?.toUpperCase(),
|
||||
athlete.rfid_tag,
|
||||
athlete.rfid_tag?.toLowerCase(),
|
||||
athlete.rfid_tag?.toUpperCase()
|
||||
].filter(Boolean));
|
||||
|
||||
const paths = [];
|
||||
possibleIds.forEach(id => {
|
||||
paths.push(`test_history/${id}`);
|
||||
paths.push(`attempts/${id}`);
|
||||
});
|
||||
|
||||
const checkPaths = async () => {
|
||||
let finalData = null;
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const historyRef = ref(rtdb, path);
|
||||
const snapshot = await get(historyRef);
|
||||
const data = snapshot.val();
|
||||
if (data) {
|
||||
finalData = data;
|
||||
setDebugKeys(Object.keys(data));
|
||||
break; // Berhenti jika sudah ketemu data yang valid
|
||||
}
|
||||
} catch (err) {
|
||||
setDebugKeys(["Error: " + err.message]);
|
||||
}
|
||||
}
|
||||
|
||||
if (finalData) {
|
||||
const attemptArray = [];
|
||||
|
||||
// Cek apakah data bersarang (Session -> Attempt) atau langsung Attempt
|
||||
Object.keys(finalData).forEach(sessionKey => {
|
||||
const sessionData = finalData[sessionKey];
|
||||
|
||||
if (sessionKey.startsWith('session_') && typeof sessionData === 'object') {
|
||||
// Ambil timestamp dari nama folder session (format: session_177xxxxxxxxx)
|
||||
const sessionTimestamp = parseInt(sessionKey.replace('session_', ''));
|
||||
|
||||
// Cari nilai tertinggi dalam sesi ini (dari attempt_1, 2, 3)
|
||||
let sessionMax = 0;
|
||||
let sessionMaxType = '';
|
||||
|
||||
Object.keys(sessionData).forEach(attemptKey => {
|
||||
if (attemptKey.startsWith('attempt_')) {
|
||||
const item = sessionData[attemptKey];
|
||||
const score = item.peak_newton || item.force || 0;
|
||||
if (Number(score) > sessionMax) {
|
||||
sessionMax = Number(score);
|
||||
sessionMaxType = item.type || 'pukulan';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (sessionMax > 0) {
|
||||
const ts = (sessionTimestamp > 1000000) ? sessionTimestamp : Date.now();
|
||||
const typeLabel = sessionMaxType.charAt(0).toUpperCase() + sessionMaxType.slice(1);
|
||||
|
||||
attemptArray.push({
|
||||
date: new Date(ts).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
|
||||
force: Number(sessionMax.toFixed(1)),
|
||||
timestamp: ts,
|
||||
label: `${typeLabel} Peak - ${new Date(ts).toLocaleDateString('id-ID', { hour: '2-digit', minute: '2-digit' })}`
|
||||
});
|
||||
}
|
||||
} else if (sessionKey.startsWith('attempt_')) {
|
||||
// Jika struktur langsung (Legacy/Lama)
|
||||
const item = sessionData;
|
||||
const score = item.peak_newton || item.force || 0;
|
||||
const ts = (item.timestamp > 1000000) ? item.timestamp : Date.now();
|
||||
|
||||
attemptArray.push({
|
||||
date: new Date(ts).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }),
|
||||
force: Number(Number(score).toFixed(1)),
|
||||
timestamp: ts,
|
||||
label: `Legacy Test #${sessionKey.replace('attempt_', '')}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (attemptArray.length > 0) {
|
||||
const sorted = attemptArray.sort((a, b) => a.timestamp - b.timestamp);
|
||||
setHistory(sorted);
|
||||
} else {
|
||||
setHistory([]);
|
||||
}
|
||||
} else {
|
||||
setHistory([]);
|
||||
setDebugKeys(["ID Tidak Ditemukan"]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkPaths();
|
||||
}, [isOpen, athlete]);
|
||||
|
||||
const maxPower = history.length > 0 ? Math.max(...history.map(d => d.force)) : 0;
|
||||
const avgPower = history.length > 0 ? (history.reduce((acc, curr) => acc + curr.force, 0) / history.length).toFixed(1) : 0;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl bg-white/95 backdrop-blur-xl border-black/5">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-2xl font-bold">
|
||||
<TrendingUp className="text-primary w-6 h-6" />
|
||||
Riwayat Performa: {athlete?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grafik perkembangan kekuatan atlet berdasarkan data latihan yang tercatat.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-blue-50 p-4 rounded-2xl border border-blue-100">
|
||||
<div className="flex items-center gap-2 text-blue-600 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<Zap size={14} /> Max Power
|
||||
</div>
|
||||
<div className="text-2xl font-black text-slate-900">{maxPower} <span className="text-sm font-normal">N</span></div>
|
||||
</div>
|
||||
<div className="bg-indigo-50 p-4 rounded-2xl border border-indigo-100">
|
||||
<div className="flex items-center gap-2 text-indigo-600 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<Target size={14} /> Avg Power
|
||||
</div>
|
||||
<div className="text-2xl font-black text-slate-900">{avgPower} <span className="text-sm font-normal">N</span></div>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<Calendar size={14} /> Total Sesi
|
||||
</div>
|
||||
<div className="text-2xl font-black text-slate-900">{history.length} <span className="text-sm font-normal">Kali</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[300px] w-full mt-2">
|
||||
{loading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400">
|
||||
Memuat data grafik...
|
||||
</div>
|
||||
) : history.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="colorForce" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(val) => val.split(' ')[0]}
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(val) => `${val}N`}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-2xl shadow-2xl border border-slate-50 flex flex-col gap-1">
|
||||
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest">{data.label}</p>
|
||||
<p className="text-xl font-black italic text-primary">{data.force} <span className="text-xs font-normal">Newton</span></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="force"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={3}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorForce)"
|
||||
animationDuration={1500}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 gap-2 border-2 border-dashed border-slate-100 rounded-2xl">
|
||||
<Zap size={32} className="text-slate-200" />
|
||||
Belum ada data riwayat untuk atlet ini.
|
||||
<div className="mt-4 p-4 bg-slate-900 rounded-xl text-[10px] font-mono text-emerald-400 border border-white/10 w-full max-w-md overflow-hidden text-left">
|
||||
<div className="text-white font-bold mb-2 uppercase border-b border-white/10 pb-1">Debug Info:</div>
|
||||
<div>• Target ID: <span className="text-white">{athlete?.id}</span></div>
|
||||
<div>• Items Found: <span className="text-white">{history.length}</span></div>
|
||||
<div>• Folder Keys: <span className="text-yellow-400">{debugKeys.join(', ')}</span></div>
|
||||
<div>• DB URL: <span className="text-white">{rtdb.app.options.databaseURL}</span></div>
|
||||
<div className="mt-2 text-slate-500 italic">
|
||||
Jika "Items Found" adalah 0 padahal di Firebase ada, kemungkinan struktur data di dalam folder ID tidak sesuai standar (attempt_N).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryModal;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const ProtectedRoute = () => {
|
||||
const { user } = useAuth();
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check session storage for existing session
|
||||
const token = sessionStorage.getItem("adminToken");
|
||||
const username = sessionStorage.getItem("adminUsername");
|
||||
if (token) {
|
||||
setUser({ role: "admin", username: username });
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${API_URL}/api/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
sessionStorage.setItem("adminToken", data.token);
|
||||
sessionStorage.setItem("adminUsername", data.username);
|
||||
setUser({ role: "admin", username: data.username });
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, message: data.message };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Login failed" };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem("adminToken");
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, loading }}>
|
||||
{!loading && children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const SocketContext = createContext();
|
||||
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SocketProvider = ({ children }) => {
|
||||
const [socket, setSocket] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const SOCKET_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const newSocket = io(SOCKET_URL);
|
||||
setSocket(newSocket);
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Connected to backend socket');
|
||||
});
|
||||
|
||||
return () => newSocket.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={socket}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { initializeApp } from "firebase/app";
|
||||
import { getDatabase } from "firebase/database";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyDhFtqvaTMgYH9vyAHFclDUXiOxkl-2GqM",
|
||||
authDomain: "silat-b3100.firebaseapp.com",
|
||||
databaseURL: "https://silat-b3100-default-rtdb.firebaseio.com/",
|
||||
projectId: "silat-b3100",
|
||||
storageBucket: "silat-b3100.firebasestorage.app",
|
||||
messagingSenderId: "471266034969",
|
||||
appId: "1:471266034969:web:467de19582cf2fd3da3188"
|
||||
};
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const rtdb = getDatabase(app);
|
||||
|
||||
export { rtdb };
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
--color-chart-1: hsl(var(--chart-1));
|
||||
--color-chart-2: hsl(var(--chart-2));
|
||||
--color-chart-3: hsl(var(--chart-3));
|
||||
--color-chart-4: hsl(var(--chart-4));
|
||||
--color-chart-5: hsl(var(--chart-5));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
|
||||
--radius: 1rem;
|
||||
|
||||
--chart-1: 221 83% 53%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
background-image:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
|
||||
linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.4));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass-card {
|
||||
@apply bg-white/70 backdrop-blur-xl border border-white/20 shadow-xl shadow-primary/5;
|
||||
}
|
||||
|
||||
.glass-card-dark {
|
||||
@apply bg-slate-950/40 backdrop-blur-xl border border-slate-800/50 shadow-2xl;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-600;
|
||||
}
|
||||
|
||||
.tech-grid {
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(0, 0, 0, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-200 rounded-full hover:bg-slate-300 transition-colors;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Activity, Lock } from "lucide-react";
|
||||
|
||||
const AdminLogin = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Jika sudah login, langsung lempar ke dashboard
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
const result = await login(username, password);
|
||||
setIsLoading(false);
|
||||
if (result.success) {
|
||||
navigate("/");
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-100 via-blue-50 to-slate-100">
|
||||
{/* Decorative background blobs */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-400/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-indigo-400/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md px-6">
|
||||
{/* Branding */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<img src="/logo.png" alt="IKSPI" className="h-32 w-auto object-contain drop-shadow-2xl" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900">
|
||||
IMPACT<span className="text-primary">MONITOR</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-base mt-2 font-medium">Sistem Monitoring Atlet Silat Pro Edition</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-black/5 shadow-2xl shadow-blue-100 bg-white/90 backdrop-blur-2xl py-2">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="flex items-center gap-3 text-2xl font-bold">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Lock className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
Login Admin
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">Silakan masukkan kata sandi untuk mengelola data atlet.</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid w-full items-center gap-2.5">
|
||||
<Label htmlFor="username">Username Admin</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Masukkan username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="h-12 bg-white/50 border-slate-200 focus:ring-2 focus:ring-primary/20 transition-all text-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-2.5">
|
||||
<Label htmlFor="password text-slate-700">Kata Sandi Sistem</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-12 bg-white/50 border-slate-200 focus:ring-2 focus:ring-primary/20 transition-all text-lg"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-4 py-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2 pb-8">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-semibold shadow-lg shadow-primary/25 hover:shadow-primary/40 transition-all active:scale-[0.98]"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Memverifikasi..." : "Masuk ke Dashboard"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-slate-400 mt-10">
|
||||
© {new Date().getFullYear()} ImpactMonitor Pro · Dirancang untuk IKSPI Kera Sakti
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLogin;
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { User, Activity, Trash2, UserPlus, Users, Zap, TrendingUp, Info, History } from 'lucide-react';
|
||||
import HistoryModal from '../components/HistoryModal';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rtdb } from "../firebase";
|
||||
import { ref, onValue, remove } from "firebase/database";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const CATEGORIES = ["Usia dini A", "Usia dini B", "Pra remaja", "Remaja", "Dewasa"];
|
||||
|
||||
const StatCard = ({ title, value, icon: Icon, color }) => (
|
||||
<Card className="glass-card overflow-hidden transition-all hover:scale-[1.02] shadow-xl border-none">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{title}</p>
|
||||
<h3 className="text-3xl font-black mt-1 italic tracking-tighter">{value}</h3>
|
||||
</div>
|
||||
<div className={cn("p-4 rounded-[1.25rem]", color)}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const [athletes, setAthletes] = useState([]);
|
||||
const [serverStatus, setServerStatus] = useState('Menghubungkan...');
|
||||
const [sensorStatus, setSensorStatus] = useState('Memeriksa...');
|
||||
const [serverColor, setServerColor] = useState('text-slate-400 bg-slate-50');
|
||||
const [sensorColor, setSensorColor] = useState('text-slate-400 bg-slate-50');
|
||||
|
||||
const [selectedAthlete, setSelectedAthlete] = useState(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
|
||||
// Cek status App Server (Node.js backend) via HTTP ping setiap 4 detik
|
||||
// Lebih cepat dari Socket.IO ping timeout (20-30 detik)
|
||||
useEffect(() => {
|
||||
let pingInterval = null;
|
||||
|
||||
const checkServer = async () => {
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${API_URL}/api/athletes`, {
|
||||
signal: AbortSignal.timeout(2000) // timeout 2 detik
|
||||
});
|
||||
if (res.ok || res.status < 500) {
|
||||
setServerStatus('Online');
|
||||
setServerColor('text-emerald-600 bg-emerald-50');
|
||||
} else {
|
||||
setServerStatus('Error');
|
||||
setServerColor('text-orange-500 bg-orange-50');
|
||||
}
|
||||
} catch {
|
||||
setServerStatus('Offline');
|
||||
setServerColor('text-red-500 bg-red-50');
|
||||
}
|
||||
};
|
||||
|
||||
// Cek langsung saat komponen mount
|
||||
checkServer();
|
||||
// Lalu cek setiap 4 detik
|
||||
pingInterval = setInterval(checkServer, 4000);
|
||||
|
||||
return () => clearInterval(pingInterval);
|
||||
}, []);
|
||||
|
||||
|
||||
// Cek status sensor IoT dari Firebase RTDB (timestamp heartbeat)
|
||||
// Firmware menulis Unix timestamp ke /system/last_seen setiap 8 detik
|
||||
// Dashboard cek apakah timestamp sudah >15 detik → Offline
|
||||
useEffect(() => {
|
||||
const lastSeenRef = ref(rtdb, '/system/last_seen');
|
||||
let lastSeenValue = 0;
|
||||
let checkInterval = null;
|
||||
|
||||
const unsubscribe = onValue(lastSeenRef, (snapshot) => {
|
||||
const ts = snapshot.val();
|
||||
if (ts && ts > 0) {
|
||||
lastSeenValue = ts; // Unix timestamp dari firmware
|
||||
}
|
||||
});
|
||||
|
||||
// Cek staleness setiap 5 detik
|
||||
checkInterval = setInterval(() => {
|
||||
if (lastSeenValue === 0) {
|
||||
setSensorStatus('Tidak Terdeteksi');
|
||||
setSensorColor('text-slate-400 bg-slate-50');
|
||||
return;
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const ageSeconds = nowSec - lastSeenValue;
|
||||
|
||||
if (ageSeconds <= 15) {
|
||||
// Heartbeat masih segar → Online
|
||||
setSensorStatus('Online');
|
||||
setSensorColor('text-emerald-600 bg-emerald-50');
|
||||
} else {
|
||||
// Heartbeat sudah lama → alat mati/putus
|
||||
setSensorStatus('Offline');
|
||||
setSensorColor('text-red-500 bg-red-50');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(checkInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const usersRef = ref(rtdb, 'users');
|
||||
const unsubscribe = onValue(usersRef, (snapshot) => {
|
||||
const data = snapshot.val();
|
||||
if (data) {
|
||||
const athletesArray = Object.keys(data).map(key => {
|
||||
const u = data[key] || {};
|
||||
const p = u.profile || {};
|
||||
return {
|
||||
id: String(key),
|
||||
name: String(p.name || u.name || 'Unknown'),
|
||||
category: String(p.category || u.category || 'N/A'),
|
||||
rfid_tag: String(p.rfid_tag || u.rfid_tag || key),
|
||||
age: Number(p.age || u.age || 0),
|
||||
height: Number(p.height || u.height || 0),
|
||||
weight: Number(p.weight || u.weight || 0)
|
||||
};
|
||||
});
|
||||
setAthletes(athletesArray);
|
||||
} else {
|
||||
setAthletes([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const deleteAthlete = async (id) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
// 1. Hapus dari Firebase (Data Profil)
|
||||
await remove(ref(rtdb, `users/${id}`));
|
||||
|
||||
// 2. Hapus dari Firebase (Data Riwayat) - Sangat Penting!
|
||||
// Kita hapus di kedua path yang mungkin digunakan
|
||||
await remove(ref(rtdb, `test_history/${id}`));
|
||||
await remove(ref(rtdb, `attempts/${id}`));
|
||||
|
||||
// 3. Sync dengan Backend (SQLite)
|
||||
// Ini untuk membersihkan data di Leaderboard lokal
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
await fetch(`${API_URL}/api/athletes/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (backendErr) {
|
||||
console.warn("Backend sync failed, but Firebase data was removed.", backendErr);
|
||||
}
|
||||
|
||||
console.log(`Athlete ${id} and all associated data deleted successfully.`);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete athlete and data", err);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryStyles = (category) => {
|
||||
const cat = String(category || '').toLowerCase();
|
||||
if (cat.includes('dini')) return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
if (cat.includes('remaja')) return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||
if (cat.includes('dewasa')) return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
return "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
const avgAge = athletes.length > 0 ? (athletes.reduce((acc, a) => acc + (Number(a.age) || 0), 0) / athletes.length).toFixed(1) : "0";
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black tracking-tighter text-slate-900 md:text-6xl italic uppercase">
|
||||
Dashboard <span className="text-primary">PRO</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-2 text-lg max-w-2xl font-bold uppercase tracking-widest text-[10px]">
|
||||
Monitoring Impact & Manajemen Atlet Real-time
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link to="/register">
|
||||
<Button size="lg" className="rounded-[1.5rem] px-8 h-16 text-base font-black shadow-2xl shadow-primary/30 transition-all hover:scale-105 active:scale-95 italic">
|
||||
<UserPlus className="mr-2 h-6 w-6" /> DAFTARKAN ATLET
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard title="Total Atlet" value={athletes.length} icon={Users} color="text-blue-600 bg-blue-50" />
|
||||
<StatCard title="Rata-rata Umur" value={`${avgAge} th`} icon={TrendingUp} color="text-emerald-600 bg-emerald-50" />
|
||||
<StatCard title="App Server" value={serverStatus} icon={Activity} color={serverColor} />
|
||||
<StatCard title="Sensor IoT" value={sensorStatus} icon={Zap} color={sensorColor} />
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
|
||||
{/* Athlete List Section */}
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-3xl font-black italic uppercase tracking-tighter">Katalog Atlet</h2>
|
||||
<Badge variant="secondary" className="bg-slate-900 text-white rounded-lg px-3 py-1 text-xs">
|
||||
{athletes.length} REKOD
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Filter:</span>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[180px] h-11 rounded-xl bg-white border-black/5 shadow-sm font-bold text-xs uppercase tracking-wider">
|
||||
<SelectValue placeholder="Pilih Kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-none shadow-2xl">
|
||||
<SelectItem value="all" className="text-xs font-bold uppercase">Semua Kategori</SelectItem>
|
||||
{CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat} value={cat} className="text-xs font-bold uppercase">{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 pb-20">
|
||||
{athletes
|
||||
.filter(a => categoryFilter === 'all' || a.category === categoryFilter)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((athlete) => (
|
||||
<Card key={athlete.id} className="group glass-card border-none hover:ring-4 hover:ring-primary/10 transition-all duration-700 overflow-hidden shadow-2xl rounded-[3rem]">
|
||||
<CardHeader className="p-0">
|
||||
<div className="h-28 bg-slate-100 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent" />
|
||||
<div className="absolute -bottom-6 left-8 p-1.5 bg-white rounded-[1.5rem] shadow-2xl">
|
||||
<div className="w-20 h-20 rounded-[1.2rem] bg-slate-950 flex items-center justify-center text-white text-3xl font-black italic">
|
||||
{String(athlete.name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-6 right-8">
|
||||
<Badge variant="outline" className="border-black/5 bg-white/50 backdrop-blur-md font-black text-[9px] tracking-[0.2em] px-3 py-1.5 uppercase rounded-full">
|
||||
ID: {String(athlete.id || "N/A").slice(-6)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-12 px-10 pb-4">
|
||||
<CardTitle className="text-3xl font-black tracking-tighter italic uppercase group-hover:text-primary transition-colors leading-none">
|
||||
{athlete.name}
|
||||
</CardTitle>
|
||||
<div className={cn("inline-block mt-3 px-4 py-1.5 rounded-full text-[9px] font-black uppercase tracking-widest border", getCategoryStyles(athlete.category))}>
|
||||
{athlete.category}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-10 pb-10">
|
||||
<div className="grid grid-cols-3 gap-6 py-6 border-y border-black/5 mb-8">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] uppercase font-black text-slate-400 mb-1 tracking-widest">Umur</p>
|
||||
<p className="text-lg font-black italic">{athlete.age} <span className="text-[10px] opacity-40">TH</span></p>
|
||||
</div>
|
||||
<div className="text-center border-x border-black/5">
|
||||
<p className="text-[10px] uppercase font-black text-slate-400 mb-1 tracking-widest">Tinggi</p>
|
||||
<p className="text-lg font-black italic">{athlete.height} <span className="text-[10px] opacity-40">CM</span></p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] uppercase font-black text-slate-400 mb-1 tracking-widest">Berat</p>
|
||||
<p className="text-lg font-black italic">{athlete.weight} <span className="text-[10px] opacity-40">KG</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Link to={`/live-test/${athlete.id}`} className="flex-1">
|
||||
<Button className="w-full rounded-2xl font-black uppercase text-xs tracking-widest h-14 shadow-xl shadow-primary/20 bg-primary hover:bg-primary/90 hover:scale-[1.03] transition-transform italic">
|
||||
<Activity className="w-4 h-4 mr-2" /> Live Test
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setSelectedAthlete(athlete); setIsHistoryOpen(true); }}
|
||||
className="h-14 w-14 rounded-2xl border-black/5 hover:border-primary/20 hover:text-primary transition-all"
|
||||
title="Lihat Riwayat"
|
||||
>
|
||||
<TrendingUp size={20} />
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-14 w-14 rounded-2xl text-slate-300 hover:text-red-500 hover:bg-red-50 hover:border-red-100 border border-black/5 transition-all">
|
||||
<Trash2 size={20} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="rounded-[3rem] border-none shadow-[0_50px_100px_rgba(0,0,0,0.3)] p-10 max-w-md">
|
||||
<DialogHeader className="pt-4">
|
||||
<div className="w-20 h-20 bg-red-100 text-red-600 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
|
||||
<Info size={40} />
|
||||
</div>
|
||||
<DialogTitle className="text-3xl font-black text-center italic tracking-tighter uppercase">Hapus Atlet</DialogTitle>
|
||||
<DialogDescription className="text-center text-slate-500 font-bold mt-2">
|
||||
Anda akan menghapus data <span className="text-slate-900">{athlete.name}</span> secara permanen. Tindakan ini tidak dapat dibatalkan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-center gap-4 pt-10">
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" className="rounded-2xl px-8 h-14 font-black uppercase text-xs tracking-widest">BATAL</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => deleteAthlete(athlete.id)} variant="destructive" className="rounded-2xl px-10 h-14 font-black uppercase text-xs tracking-widest shadow-2xl shadow-red-200 italic">
|
||||
HAPUS DATA
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{athletes.length === 0 && (
|
||||
<div className="col-span-full py-40 flex flex-col items-center justify-center glass-card border-none rounded-[4rem] text-center">
|
||||
<div className="w-32 h-32 bg-slate-50 rounded-[3rem] flex items-center justify-center mb-10 shadow-inner">
|
||||
<UserPlus size={60} className="text-slate-200" />
|
||||
</div>
|
||||
<h3 className="text-4xl font-black tracking-tighter uppercase italic mb-2">Basis Data Kosong</h3>
|
||||
<p className="text-slate-400 max-w-sm font-bold uppercase tracking-widest text-[10px] mb-12">
|
||||
Daftarkan atlet pertama Anda untuk mulai memantau performa.
|
||||
</p>
|
||||
<Link to="/register">
|
||||
<Button size="lg" className="rounded-[2rem] px-12 h-20 text-lg font-black shadow-2xl shadow-primary/30 rotate-[-2deg] hover:rotate-0 transition-transform italic">
|
||||
<UserPlus className="mr-3 h-8 w-8" /> DAFTAR SEKARANG
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HistoryModal
|
||||
athlete={selectedAthlete}
|
||||
isOpen={isHistoryOpen}
|
||||
onClose={() => setIsHistoryOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Trophy, Medal, Download, Calendar, AlertCircle, Filter, FileText, Users } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rtdb } from '../firebase';
|
||||
import { ref, onValue } from 'firebase/database';
|
||||
|
||||
const HARI_LIST = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"];
|
||||
const BULAN_LIST = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"];
|
||||
const TANGGAL_LIST = Array.from({ length: 31 }, (_, i) => String(i + 1));
|
||||
const CATEGORIES = ["Usia dini A", "Usia dini B", "Pra remaja", "Remaja", "Dewasa"];
|
||||
|
||||
const Leaderboard = () => {
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const [filteredLeaderboard, setFilteredLeaderboard] = useState([]);
|
||||
|
||||
// New Date Range State
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [strikeTypeFilter, setStrikeTypeFilter] = useState('all');
|
||||
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }));
|
||||
|
||||
const isExportReady = true;
|
||||
|
||||
useEffect(() => {
|
||||
const usersRef = ref(rtdb, 'users');
|
||||
const historyRef = ref(rtdb, 'test_history');
|
||||
|
||||
// Listen to both nodes to ensure real-time ranking update
|
||||
const unsubUsers = onValue(usersRef, (snapshot) => {
|
||||
const userData = snapshot.val() || {};
|
||||
|
||||
onValue(historyRef, (historySnapshot) => {
|
||||
const historyData = historySnapshot.val() || {};
|
||||
|
||||
const athletesArray = Object.keys(userData).map(key => {
|
||||
const profile = userData[key].profile || {};
|
||||
const athleteHistory = historyData[key] || {};
|
||||
|
||||
let bestPunch = 0;
|
||||
let bestKick = 0;
|
||||
|
||||
const processAttempt = (data, parentKey) => {
|
||||
const peak = Number(data?.peak_newton || 0);
|
||||
let ts = data?.timestamp || 0;
|
||||
const type = (data?.type || 'pukulan').toLowerCase();
|
||||
|
||||
// Perbaikan: Ambil timestamp dari nama session jika TS hardware rusak
|
||||
if (ts < 1000000000000 && parentKey && parentKey.startsWith('session_')) {
|
||||
const parsedTs = parseInt(parentKey.replace('session_', ''));
|
||||
if (!isNaN(parsedTs) && parsedTs > 1000000000000) {
|
||||
ts = parsedTs;
|
||||
}
|
||||
}
|
||||
|
||||
if (ts < 1000000000000) {
|
||||
ts = new Date('2026-04-21T12:00:00').getTime();
|
||||
}
|
||||
|
||||
const testDate = new Date(ts);
|
||||
let includeDate = true;
|
||||
|
||||
if (startDate && endDate) {
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
includeDate = testDate >= start && testDate <= end;
|
||||
}
|
||||
|
||||
if (includeDate) {
|
||||
if (type === 'pukulan' && peak > bestPunch) bestPunch = peak;
|
||||
if (type === 'tendangan' && peak > bestKick) bestKick = peak;
|
||||
}
|
||||
};
|
||||
|
||||
const traverse = (obj, parentKey = '') => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
Object.keys(obj).forEach(k => {
|
||||
if (k.startsWith('attempt_')) {
|
||||
processAttempt(obj[k], parentKey);
|
||||
} else if (typeof obj[k] === 'object') {
|
||||
traverse(obj[k], k);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(athleteHistory);
|
||||
|
||||
// Logic for display score based on filter
|
||||
let displayScore = 0;
|
||||
if (strikeTypeFilter === 'pukulan') displayScore = bestPunch;
|
||||
else if (strikeTypeFilter === 'tendangan') displayScore = bestKick;
|
||||
else displayScore = Math.max(bestPunch, bestKick);
|
||||
|
||||
return {
|
||||
id: key,
|
||||
name: profile.name || 'Unknown',
|
||||
category: profile.category || 'N/A',
|
||||
best_punch: bestPunch,
|
||||
best_kick: bestKick,
|
||||
best_score: displayScore
|
||||
};
|
||||
});
|
||||
|
||||
setLeaderboard(athletesArray);
|
||||
}, { onlyOnce: false });
|
||||
});
|
||||
|
||||
const timeInterval = setInterval(() => {
|
||||
setCurrentTime(new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }));
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
unsubUsers();
|
||||
clearInterval(timeInterval);
|
||||
};
|
||||
}, [startDate, endDate, strikeTypeFilter]); // Trigger re-fetching when dates or strike type change
|
||||
|
||||
// Apply category filter and sorting
|
||||
useEffect(() => {
|
||||
let filtered = [...leaderboard];
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
filtered = filtered.filter(a => a.category === categoryFilter);
|
||||
}
|
||||
|
||||
// Only show those with scores > 0
|
||||
filtered = filtered.filter(a => a.best_score > 0);
|
||||
|
||||
// Sort descending
|
||||
filtered.sort((a, b) => b.best_score - a.best_score);
|
||||
|
||||
setFilteredLeaderboard(filtered);
|
||||
}, [leaderboard, categoryFilter]);
|
||||
|
||||
const getRankIcon = (index) => {
|
||||
if (index === 0) return <div className="w-12 h-12 rounded-2xl bg-yellow-400 flex items-center justify-center shadow-lg shadow-yellow-200 ring-4 ring-yellow-50"><Trophy className="text-white w-6 h-6" /></div>;
|
||||
if (index === 1) return <div className="w-10 h-10 rounded-xl bg-slate-300 flex items-center justify-center shadow-lg shadow-slate-200"><Medal className="text-white w-5 h-5" /></div>;
|
||||
if (index === 2) return <div className="w-10 h-10 rounded-xl bg-amber-600 flex items-center justify-center shadow-lg shadow-amber-200"><Medal className="text-white w-5 h-5" /></div>;
|
||||
return <div className="w-10 h-10 flex items-center justify-center font-black text-slate-300">#{index + 1}</div>;
|
||||
};
|
||||
|
||||
const exportToPDF = () => {
|
||||
const generatePDF = (ikspiLogo) => {
|
||||
try {
|
||||
const doc = new jsPDF();
|
||||
const pageWidth = doc.internal.pageSize.width;
|
||||
|
||||
// --- BACKGROUND PUTIH ---
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, pageWidth, doc.internal.pageSize.height, 'F');
|
||||
|
||||
// --- TOP KIRI: LOGO IKSPI + IMPACT ---
|
||||
let impactStartX = 14;
|
||||
|
||||
// Tambahkan Logo IKSPI Paling Kiri (jika ada)
|
||||
if (ikspiLogo) {
|
||||
doc.addImage(ikspiLogo, 'PNG', 14, 5, 14, 14);
|
||||
impactStartX = 32; // Geser teks Impact ke kanan logo
|
||||
}
|
||||
|
||||
// Simulasi Logo "i" warna biru
|
||||
doc.setTextColor(37, 99, 235); // blue-600
|
||||
doc.setFontSize(22);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("i", impactStartX, 15);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(15, 23, 42); // slate-900
|
||||
doc.text("IMPACT", impactStartX + 6, 13);
|
||||
doc.setFontSize(6);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text("MONITOR PRO", impactStartX + 6, 17);
|
||||
|
||||
// --- TOP KANAN: INFO CETAK ---
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const realTimeJam = new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
doc.text(`Dicetak pada: ${new Date().toLocaleDateString('id-ID')} ${realTimeJam}`, pageWidth - 14, 12, { align: "right" });
|
||||
doc.text(`Sistem: Impact Monitor Pro`, pageWidth - 14, 16, { align: "right" });
|
||||
|
||||
// --- JUDUL UTAMA ---
|
||||
doc.setTextColor(15, 23, 42); // navy gelap
|
||||
doc.setFontSize(26);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("HALL OF FAME", 14, 33);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text("Laporan Perankingan Atlet Impact Monitor Pro", 14, 40);
|
||||
|
||||
// --- PERIODE DATA ---
|
||||
const formattedRange = (startDate && endDate) ? `${startDate} s/d ${endDate}` : "Keseluruhan Waktu";
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(71, 85, 105);
|
||||
doc.text(`Periode Data: ${formattedRange}`, 14, 48);
|
||||
|
||||
// --- 📊 4 KOTAK SUMMARY ---
|
||||
const totalAtlet = filteredLeaderboard.length;
|
||||
let maxPower = 0;
|
||||
let sumPower = 0;
|
||||
const categoriesSet = new Set();
|
||||
|
||||
filteredLeaderboard.forEach(a => {
|
||||
const p = a.best_score || 0;
|
||||
if (p > maxPower) maxPower = p;
|
||||
sumPower += p;
|
||||
if (a.category && a.category !== 'N/A') categoriesSet.add(a.category);
|
||||
});
|
||||
const avgPower = totalAtlet > 0 ? (sumPower / totalAtlet).toFixed(2) : 0;
|
||||
const totalCategories = categoriesSet.size;
|
||||
|
||||
const cardWidth = (pageWidth - 28 - (3 * 4)) / 4; // 4 kotak dengan margin
|
||||
const summaryData = [
|
||||
{ title: "TOTAL ATLET", value: totalAtlet, sub: "Atlet" },
|
||||
{ title: "RATA-RATA POWER", value: avgPower, sub: "Newton (N)" },
|
||||
{ title: "POWER TERTINGGI", value: maxPower > 0 ? maxPower.toFixed(2) : 0, sub: "Newton (N)" },
|
||||
{ title: "KATEGORI", value: totalCategories, sub: "Kategori" }
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = 14 + (i * (cardWidth + 4));
|
||||
const y = 55;
|
||||
doc.setDrawColor(226, 232, 240); // slate-200 border
|
||||
doc.setFillColor(255, 255, 255); // putih
|
||||
doc.roundedRect(x, y, cardWidth, 22, 2, 2, 'FD');
|
||||
|
||||
doc.setFontSize(6);
|
||||
doc.setTextColor(148, 163, 184); // slate-400
|
||||
doc.text(summaryData[i].title, x + cardWidth/2, y + 7, { align: "center" });
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(15, 23, 42); // slate-900
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(`${summaryData[i].value}`, x + cardWidth/2, y + 14, { align: "center" });
|
||||
|
||||
doc.setFontSize(6);
|
||||
doc.setTextColor(148, 163, 184);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(summaryData[i].sub, x + cardWidth/2, y + 19, { align: "center" });
|
||||
}
|
||||
|
||||
// --- 🏆 TOP 3 JUDUL ---
|
||||
doc.setTextColor(250, 204, 21); // kuning emas
|
||||
|
||||
// Menggambar 3 titik/lingkaran emas sebagai pengganti bintang yang error
|
||||
doc.setFillColor(250, 204, 21);
|
||||
doc.circle(pageWidth/2 - 6, 86, 0.8, 'F');
|
||||
doc.circle(pageWidth/2, 86, 1.2, 'F');
|
||||
doc.circle(pageWidth/2 + 6, 86, 0.8, 'F');
|
||||
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.setFontSize(11);
|
||||
doc.text("TOP 3 ATLET", pageWidth/2, 93, { align: "center" });
|
||||
|
||||
doc.setDrawColor(250, 204, 21);
|
||||
doc.line(pageWidth/2 - 25, 91, pageWidth/2 - 15, 91);
|
||||
doc.line(pageWidth/2 + 15, 91, pageWidth/2 + 25, 91);
|
||||
|
||||
// --- 🏆 TOP 3 PODIUM CARDS ---
|
||||
const top3Width = 50;
|
||||
const rank1X = (pageWidth / 2) - (top3Width / 2);
|
||||
const rank2X = rank1X - top3Width - 8;
|
||||
const rank3X = rank1X + top3Width + 8;
|
||||
|
||||
const drawTopCard = (x, y, width, height, rank, athlete, colorHex) => {
|
||||
if (!athlete) return;
|
||||
|
||||
// Hex to RGB untuk drawColor
|
||||
const r = parseInt(colorHex.substring(1,3), 16);
|
||||
const g = parseInt(colorHex.substring(3,5), 16);
|
||||
const b = parseInt(colorHex.substring(5,7), 16);
|
||||
|
||||
// Background Kartu
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(r, g, b); // Border sesuai medali
|
||||
doc.roundedRect(x, y, width, height, 3, 3, 'FD');
|
||||
|
||||
// Lingkaran Medali
|
||||
doc.setFillColor(r, g, b);
|
||||
doc.circle(x + width/2, y + 12, 6, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(`${rank}`, x + width/2, y + 15.5, { align: "center" });
|
||||
|
||||
// Nama Atlet
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.setFontSize(10);
|
||||
const name = athlete.name.length > 12 ? athlete.name.substring(0, 10) + '...' : athlete.name;
|
||||
doc.text(name.toUpperCase(), x + width/2, y + 26, { align: "center" });
|
||||
|
||||
// Kategori
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(athlete.category, x + width/2, y + 31, { align: "center" });
|
||||
|
||||
// Power Score
|
||||
doc.setTextColor(37, 99, 235); // Biru
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(`${athlete.best_score ? athlete.best_score.toFixed(2) : 0} N`, x + width/2, y + 37, { align: "center" });
|
||||
};
|
||||
|
||||
// Rank 2 (Kiri)
|
||||
drawTopCard(rank2X, 103, top3Width, 42, 2, filteredLeaderboard[1], "#94A3B8"); // Silver
|
||||
// Rank 1 (Tengah - Lebih Tinggi & Lebar)
|
||||
drawTopCard(rank1X, 98, top3Width + 4, 47, 1, filteredLeaderboard[0], "#FBBF24"); // Emas
|
||||
// Rank 3 (Kanan)
|
||||
drawTopCard(rank3X, 103, top3Width, 42, 3, filteredLeaderboard[2], "#D97706"); // Perunggu
|
||||
|
||||
// --- 📋 TABEL PERINGKAT LENGKAP ---
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.text("PERINGKAT LENGKAP", 14, 158);
|
||||
|
||||
// Dinamis Kolom berdasarkan Filter
|
||||
let tableColumn = ["RANK", "NAMA ATLET", "KATEGORI"];
|
||||
if (strikeTypeFilter === 'all') {
|
||||
tableColumn.push("PUKULAN (N)", "TENDANGAN (N)", "BEST SCORE (N)");
|
||||
} else if (strikeTypeFilter === 'pukulan') {
|
||||
tableColumn.push("PUKULAN (N)");
|
||||
} else {
|
||||
tableColumn.push("TENDANGAN (N)");
|
||||
}
|
||||
|
||||
const tableRows = filteredLeaderboard.map((athlete, index) => {
|
||||
const row = [
|
||||
`#${index + 1}`,
|
||||
athlete.name.toUpperCase(),
|
||||
athlete.category
|
||||
];
|
||||
|
||||
if (strikeTypeFilter === 'all') {
|
||||
row.push(
|
||||
athlete.best_punch ? athlete.best_punch.toFixed(1) : '-',
|
||||
athlete.best_kick ? athlete.best_kick.toFixed(1) : '-',
|
||||
athlete.best_score ? athlete.best_score.toFixed(2) : '0.00'
|
||||
);
|
||||
} else if (strikeTypeFilter === 'pukulan') {
|
||||
row.push(athlete.best_punch ? athlete.best_punch.toFixed(2) : '0.00');
|
||||
} else {
|
||||
row.push(athlete.best_kick ? athlete.best_kick.toFixed(2) : '0.00');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
// Style Kolom Dinamis
|
||||
const colStyles = {
|
||||
0: { halign: 'center', fontStyle: 'bold', textColor: [37, 99, 235] },
|
||||
1: { halign: 'left', fontStyle: 'bold', textColor: [15, 23, 42] },
|
||||
2: { halign: 'center', textColor: [100, 116, 139] }
|
||||
};
|
||||
|
||||
if (strikeTypeFilter === 'all') {
|
||||
colStyles[3] = { halign: 'center', textColor: [15, 23, 42] };
|
||||
colStyles[4] = { halign: 'center', textColor: [15, 23, 42] };
|
||||
colStyles[5] = { halign: 'center', fontStyle: 'bold', textColor: [37, 99, 235] };
|
||||
} else {
|
||||
colStyles[3] = { halign: 'center', fontStyle: 'bold', textColor: [15, 23, 42] };
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
head: [tableColumn],
|
||||
body: tableRows,
|
||||
startY: 163,
|
||||
theme: 'grid',
|
||||
headStyles: {
|
||||
fillColor: [15, 23, 42],
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
halign: 'center',
|
||||
fontSize: 8,
|
||||
cellPadding: 4
|
||||
},
|
||||
columnStyles: colStyles,
|
||||
alternateRowStyles: { fillColor: [248, 250, 252] },
|
||||
styles: { fontSize: 7, cellPadding: 4, lineColor: [226, 232, 240], lineWidth: 0.1 },
|
||||
|
||||
// --- FOOTER BAWAH ---
|
||||
didDrawPage: function (data) {
|
||||
let pageCount = doc.internal.getNumberOfPages();
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(148, 163, 184); // slate-400
|
||||
doc.text("Laporan ini digenerate secara otomatis oleh Impact Monitor Pro", 14, doc.internal.pageSize.height - 12);
|
||||
doc.text("Sistem pemantauan dan analisis performa atlet berbasis teknologi.", 14, doc.internal.pageSize.height - 8);
|
||||
|
||||
doc.text(`Halaman ${pageCount} dari {total_pages}`, doc.internal.pageSize.width - 14, doc.internal.pageSize.height - 10, { align: 'right' });
|
||||
}
|
||||
});
|
||||
|
||||
// Kalkulasi Total Halaman untuk Footer
|
||||
if (typeof doc.putTotalPages === 'function') {
|
||||
doc.putTotalPages("{total_pages}");
|
||||
}
|
||||
|
||||
doc.save(`Leaderboard_Silat_${startDate || 'All'}_to_${endDate || 'Time'}.pdf`);
|
||||
} catch (error) {
|
||||
console.error("Error PDF Generate:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// --- PROSES MUAT LOGO ---
|
||||
// Jika ada logo.png di dalam folder public, akan otomatis masuk ke pojok atas
|
||||
const img = new Image();
|
||||
img.src = '/logo.png';
|
||||
img.onload = () => generatePDF(img);
|
||||
img.onerror = () => generatePDF(null); // Terus lanjut tanpa logo jika gagal dimuat
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-yellow-100 rounded-xl">
|
||||
<Trophy className="text-yellow-600 w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-slate-400 leading-none">Hall of Fame</h2>
|
||||
</div>
|
||||
<h1 className="text-5xl font-black tracking-tighter text-slate-900">
|
||||
Top <span className="text-primary italic">Performers</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Filter Bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 p-6 bg-white/50 backdrop-blur-md rounded-[2rem] border border-white/20 shadow-xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Kategori</label>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[180px] h-11 rounded-xl bg-white border-none shadow-sm font-bold">
|
||||
<SelectValue placeholder="Semua Kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-none shadow-2xl">
|
||||
<SelectItem value="all">Semua Kategori</SelectItem>
|
||||
{CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Jenis Serangan</label>
|
||||
<Select value={strikeTypeFilter} onValueChange={setStrikeTypeFilter}>
|
||||
<SelectTrigger className="w-[150px] h-11 rounded-xl bg-white border-none shadow-sm font-bold">
|
||||
<SelectValue placeholder="Semua Serangan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-none shadow-2xl">
|
||||
<SelectItem value="all">Semua Serangan</SelectItem>
|
||||
<SelectItem value="pukulan">Pukulan Saja</SelectItem>
|
||||
<SelectItem value="tendangan">Tendangan Saja</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Dari</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="h-11 px-4 rounded-xl bg-white border-none shadow-sm font-bold text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Sampai</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="h-11 px-4 rounded-xl bg-white border-none shadow-sm font-bold text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2 mt-auto pb-0.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 px-6 rounded-xl border-blue-200 text-blue-600 hover:bg-blue-50 font-bold"
|
||||
onClick={() => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setCategoryFilter('all');
|
||||
setStrikeTypeFilter('all');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-11 px-6 rounded-xl bg-slate-200 text-slate-700 hover:bg-slate-300 font-bold ml-4"
|
||||
onClick={exportToPDF}
|
||||
>
|
||||
<Download size={18} className="mr-2" /> Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Leaderboard Table */}
|
||||
<Card className="glass-card border-none rounded-[3.5rem] shadow-2xl overflow-hidden p-0">
|
||||
<div className="p-10 border-b border-black/5 bg-slate-900 text-white flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary/20 rounded-2xl flex items-center justify-center">
|
||||
<Filter size={24} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-black italic tracking-tight">Katalog Peringkat</h3>
|
||||
<p className="text-xs font-black uppercase tracking-widest text-slate-500">{filteredLeaderboard.length} Rekod Atlet</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary text-[9px] font-black uppercase px-2 py-0.5 rounded-md">
|
||||
{(startDate && endDate) ? `${startDate} — ${endDate}` : "Semua Waktu"}
|
||||
</Badge>
|
||||
<p className="text-[10px] font-black uppercase text-slate-500">Update Terakhir</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">{currentTime} WIB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[700px] overflow-auto custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="w-[120px] h-16 px-10 text-[10px] font-black uppercase tracking-widest text-slate-400">Peringkat</TableHead>
|
||||
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400">Data Atlet</TableHead>
|
||||
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400">Kategori</TableHead>
|
||||
|
||||
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'pukulan') && (
|
||||
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400 text-center">Pukulan (N)</TableHead>
|
||||
)}
|
||||
|
||||
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'tendangan') && (
|
||||
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400 text-center">Tendangan (N)</TableHead>
|
||||
)}
|
||||
|
||||
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400 text-right pr-10">Power Score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLeaderboard.map((athlete, index) => (
|
||||
<TableRow key={`${athlete.id}-${index}`} className="border-b border-black/5 hover:bg-blue-50/30 transition-colors group">
|
||||
<TableCell className="px-10 py-6">{getRankIcon(index)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-black tracking-tight text-slate-900 group-hover:text-primary transition-colors">{athlete.name}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">Athlete ID: {athlete.id || 'N/A'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="rounded-lg bg-slate-100/50 border-black/5 font-bold px-3 py-1">
|
||||
{athlete.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'pukulan') && (
|
||||
<TableCell className="text-center">
|
||||
<span className={cn(
|
||||
"text-lg font-bold tracking-tighter",
|
||||
(strikeTypeFilter === 'pukulan' || (athlete.best_punch >= athlete.best_kick && athlete.best_punch > 0)) ? "text-primary" : "text-slate-400"
|
||||
)}>
|
||||
{athlete.best_punch ? athlete.best_punch.toFixed(1) : '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'tendangan') && (
|
||||
<TableCell className="text-center">
|
||||
<span className={cn(
|
||||
"text-lg font-bold tracking-tighter",
|
||||
(strikeTypeFilter === 'tendangan' || (athlete.best_kick > athlete.best_punch)) ? "text-primary" : "text-slate-400"
|
||||
)}>
|
||||
{athlete.best_kick ? athlete.best_kick.toFixed(1) : '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell className="text-right pr-10">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-2xl font-black italic tracking-tighter text-slate-950">
|
||||
{athlete.best_score ? athlete.best_score.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
<Badge variant="secondary" className="bg-slate-100 text-[8px] font-black uppercase px-2 py-0.5 rounded-md border-none">
|
||||
{strikeTypeFilter === 'pukulan' ? 'Pukulan' : (strikeTypeFilter === 'tendangan' ? 'Tendangan' : (athlete.best_punch >= athlete.best_kick ? 'Pukulan' : 'Tendangan'))}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredLeaderboard.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={strikeTypeFilter === 'all' ? 6 : 5} className="text-center py-40">
|
||||
<div className="flex flex-col items-center">
|
||||
<Users size={64} className="text-slate-100 mb-4" />
|
||||
<span className="font-bold text-slate-300">Belum Ada Data Tersedia untuk filter ini</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Leaderboard;
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { rtdb } from '../firebase';
|
||||
import { ref, onValue, get, set, off, remove } from 'firebase/database';
|
||||
import { Play, Square, Activity, Target, Zap, Waves, ChevronUp, RotateCcw } from 'lucide-react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine
|
||||
} from 'recharts';
|
||||
|
||||
const GRAPH_BARS = 150;
|
||||
|
||||
const LiveTest = () => {
|
||||
const { id } = useParams();
|
||||
const [athlete, setAthlete] = useState(null);
|
||||
const [currentForce, setCurrentForce] = useState(0);
|
||||
const [sessionPeak, setSessionPeak] = useState(0);
|
||||
const [attempts, setAttempts] = useState([]);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [bestScore, setBestScore] = useState(0);
|
||||
const [forceHistory, setForceHistory] = useState(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
|
||||
const [strikeType, setStrikeType] = useState('pukulan');
|
||||
|
||||
// 1. Fetch Athlete Details
|
||||
useEffect(() => {
|
||||
const fetchAthlete = async () => {
|
||||
try {
|
||||
const athleteRef = ref(rtdb, `users/${id}`);
|
||||
const snapshot = await get(athleteRef);
|
||||
if (snapshot.exists()) {
|
||||
const data = snapshot.val();
|
||||
setAthlete(data.profile ? { ...data.profile, id } : { id, ...data });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching athlete:", err);
|
||||
}
|
||||
};
|
||||
fetchAthlete();
|
||||
}, [id]);
|
||||
|
||||
// 2. Listen to Live Data and History
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const statusRef = ref(rtdb, 'system/test_status');
|
||||
const liveRef = ref(rtdb, 'realtime/current_force');
|
||||
const historyRef = ref(rtdb, `test_history/${id}`);
|
||||
|
||||
onValue(statusRef, (snapshot) => {
|
||||
const status = snapshot.val();
|
||||
const testing = status === 'measuring';
|
||||
setIsTesting(testing);
|
||||
if (testing) {
|
||||
setSessionPeak(0);
|
||||
setCurrentForce(0);
|
||||
}
|
||||
});
|
||||
|
||||
onValue(liveRef, (snapshot) => {
|
||||
const force = Number(snapshot.val() || 0);
|
||||
setCurrentForce(force);
|
||||
if (force > sessionPeak) {
|
||||
setSessionPeak(force);
|
||||
}
|
||||
// Rolling graph history (Muncul dari kanan ke kiri - sinkron dengan Monitor)
|
||||
setForceHistory(prev => {
|
||||
const newData = { time: 0, value: force };
|
||||
// Masukkan data baru di awal [newData], buang yang terakhir di belakang
|
||||
const updated = [newData, ...prev.slice(0, -1)];
|
||||
// Maintain indices for re-rendering
|
||||
return updated.map((d, idx) => ({ ...d, time: idx }));
|
||||
});
|
||||
});
|
||||
|
||||
onValue(historyRef, (snapshot) => {
|
||||
const data = snapshot.val();
|
||||
if (data) {
|
||||
const attemptArray = [];
|
||||
let max = 0;
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key.startsWith('attempt_')) {
|
||||
const score = data[key].peak_newton || 0;
|
||||
attemptArray.push({
|
||||
attemptNumber: key.split('_')[1],
|
||||
force: score
|
||||
});
|
||||
if (score > max) max = score;
|
||||
}
|
||||
});
|
||||
setAttempts(attemptArray.sort((a, b) => a.attemptNumber - b.attemptNumber));
|
||||
setBestScore(max);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
off(statusRef);
|
||||
off(liveRef);
|
||||
off(historyRef);
|
||||
};
|
||||
}, [id, sessionPeak]);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const nextAttempt = attempts.length + 1;
|
||||
// Format command: ID|Attempt|StrikeType
|
||||
await set(ref(rtdb, 'test_command'), `${id}|${nextAttempt}|${strikeType}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to start test:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAttempts = async () => {
|
||||
if (!id || isTesting) return;
|
||||
try {
|
||||
await remove(ref(rtdb, `test_history/${id}`));
|
||||
setAttempts([]);
|
||||
setBestScore(0);
|
||||
} catch (err) {
|
||||
console.error("Failed to reset attempts:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await set(ref(rtdb, 'system/test_status'), 'idle');
|
||||
await set(ref(rtdb, 'test_command'), '');
|
||||
} catch (err) {
|
||||
console.error("Failed to stop test:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!athlete) return <div className="text-center p-20 text-slate-400 font-bold italic">Memuat Data Atlet...</div>;
|
||||
|
||||
const forcePercentage = Math.min(100, (currentForce / 1000) * 100);
|
||||
const peakPercentage = Math.min(100, (sessionPeak / 1000) * 100);
|
||||
|
||||
// Font size based on digit count (Sinkron dengan Monitor)
|
||||
const gaugeFontSize = currentForce >= 10000
|
||||
? 'text-[5rem] md:text-[7rem]'
|
||||
: currentForce >= 1000
|
||||
? 'text-[7rem] md:text-[10rem]'
|
||||
: currentForce >= 100
|
||||
? 'text-[9rem] md:text-[13rem]'
|
||||
: 'text-[11rem] md:text-[16rem]';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto p-4 animate-in fade-in duration-700">
|
||||
{/* Header Section */}
|
||||
<header className="flex flex-col lg:row justify-between items-center bg-slate-900 text-white p-8 rounded-[3rem] shadow-2xl gap-8 border border-white/5 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-10 opacity-5 group-hover:rotate-12 transition-transform duration-1000">
|
||||
<Zap className="w-40 h-40" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 relative z-10 w-full lg:w-auto">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-3xl bg-primary flex items-center justify-center border-4 border-white/10 shadow-inner group-hover:scale-110 transition-transform">
|
||||
<span className="text-4xl font-black italic">{athlete.name?.charAt(0)}</span>
|
||||
</div>
|
||||
{isTesting && (
|
||||
<div className="absolute -top-2 -right-2 bg-red-500 w-6 h-6 rounded-full animate-ping" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-5xl font-black tracking-tighter italic uppercase leading-none mb-2">{athlete.name}</h1>
|
||||
<div className="flex gap-4">
|
||||
<span className="bg-white/10 px-3 py-1 rounded-full text-[10px] font-black tracking-[0.2em] uppercase">{athlete.category || 'Atlete'}</span>
|
||||
<span className="bg-white/10 px-3 py-1 rounded-full text-[10px] font-black tracking-[0.2em] uppercase">{athlete.weight || '-'} KG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 w-full lg:w-auto">
|
||||
<div className="bg-white/5 p-6 rounded-[2rem] border border-white/10 flex-1 lg:min-w-[200px] text-center">
|
||||
<span className="block text-slate-500 text-[10px] uppercase font-black tracking-widest mb-1">Impact Tertinggi</span>
|
||||
<span className="text-5xl font-black text-primary italic tracking-tighter">
|
||||
{bestScore.toFixed(0)} <span className="text-sm">N</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/5 p-6 rounded-[2rem] border border-white/10 flex-1 lg:min-w-[200px] text-center">
|
||||
<span className="block text-slate-500 text-[10px] uppercase font-black tracking-widest mb-1">Status Sistem</span>
|
||||
<span className={cn(
|
||||
"text-xl font-black italic uppercase tracking-widest",
|
||||
isTesting ? "text-red-500" : "text-emerald-500"
|
||||
)}>
|
||||
{isTesting ? "MEASURING" : "READY"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Main Gauge Display */}
|
||||
<div className="lg:col-span-3 space-y-8">
|
||||
<div className="bg-white rounded-[4rem] shadow-2xl p-12 border border-black/5 flex flex-col md:flex-row gap-12 min-h-[650px] relative overflow-hidden">
|
||||
|
||||
{/* Background Deco */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[20rem] font-black text-black/[0.02] select-none italic tracking-tighter">
|
||||
FORCE
|
||||
</div>
|
||||
|
||||
{/* Power Meter Vertical */}
|
||||
<div className="relative w-full md:w-40 h-[450px] md:h-full bg-slate-100 rounded-[3rem] p-4 flex flex-col-reverse group">
|
||||
{/* Scale Indicators */}
|
||||
<div className="absolute -left-10 h-[80%] my-auto inset-y-0 flex flex-col justify-between text-[10px] font-black text-slate-400">
|
||||
<span>1000</span>
|
||||
<span>750</span>
|
||||
<span>500</span>
|
||||
<span>250</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
|
||||
{/* The Main Power Bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-[2.5rem] transition-all duration-300 ease-out shadow-2xl relative",
|
||||
currentForce > 800 ? "bg-gradient-to-t from-orange-500 to-red-600" :
|
||||
currentForce > 400 ? "bg-gradient-to-t from-emerald-400 to-yellow-500" :
|
||||
"bg-gradient-to-t from-blue-500 to-emerald-400"
|
||||
)}
|
||||
style={{ height: `${forcePercentage}%` }}
|
||||
>
|
||||
{/* Impact Glow */}
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-white opacity-20 blur-xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Ghost Peak Marker */}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[calc(100%+16px)] h-1 bg-slate-400 shadow-lg z-10 transition-all duration-700"
|
||||
style={{ bottom: `calc(${peakPercentage}% + 16px)` }}
|
||||
>
|
||||
<div className="absolute right-0 -top-6 text-[10px] font-black bg-slate-400 text-white px-2 py-1 rounded-md">
|
||||
PEAK: {sessionPeak.toFixed(0)}N
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Textual Feedback */}
|
||||
<div className="flex-1 flex flex-col justify-between py-4 relative z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Waves className="text-primary w-6 h-6 animate-pulse" />
|
||||
<h3 className="text-xl font-black tracking-tight uppercase">Live Impact Monitoring</h3>
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium max-w-sm mb-8 uppercase text-xs tracking-widest leading-relaxed">
|
||||
Sensor mendeteksi osilasi gaya pada 100Hz. Pukul target untuk melihat lonjakan energi real-time.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Strike Type Toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Jenis Serangan</label>
|
||||
<div className="flex p-1.5 bg-slate-100 rounded-3xl gap-2 w-full max-w-xs">
|
||||
<button
|
||||
onClick={() => setStrikeType('pukulan')}
|
||||
disabled={isTesting}
|
||||
className={cn(
|
||||
"flex-1 h-12 rounded-2xl font-black text-[10px] transition-all uppercase tracking-widest",
|
||||
strikeType === 'pukulan'
|
||||
? "bg-white text-primary shadow-lg"
|
||||
: "text-slate-400 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Pukulan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStrikeType('tendangan')}
|
||||
disabled={isTesting}
|
||||
className={cn(
|
||||
"flex-1 h-12 rounded-2xl font-black text-[10px] transition-all uppercase tracking-widest",
|
||||
strikeType === 'tendangan'
|
||||
? "bg-white text-primary shadow-lg"
|
||||
: "text-slate-400 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Tendangan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-[2.5rem] border border-black/5 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-1 block">Live Pressure</span>
|
||||
<div className={cn(
|
||||
"font-black italic tracking-tighter text-slate-900 leading-none transition-all duration-75",
|
||||
gaugeFontSize
|
||||
)}>
|
||||
{currentForce.toFixed(0)}
|
||||
</div>
|
||||
<span className="text-2xl font-black text-primary italic tracking-tighter mt-2 uppercase">Newton</span>
|
||||
</div>
|
||||
|
||||
{sessionPeak > 0 && (
|
||||
<div className="bg-primary/5 p-6 rounded-[2.5rem] border border-primary/10 animate-in zoom-in-95">
|
||||
<span className="text-[10px] font-black uppercase text-primary tracking-widest mb-1 block">Puncak Sesi Ini</span>
|
||||
<div className="text-4xl font-black italic tracking-tighter text-primary">
|
||||
{sessionPeak.toFixed(0)} <span className="text-lg uppercase">Newton</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-12">
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isTesting}
|
||||
className={cn(
|
||||
"h-24 rounded-[2.5rem] text-xl font-black italic tracking-tighter flex items-center justify-center gap-4 transition-all overflow-hidden relative",
|
||||
isTesting
|
||||
? "bg-slate-100 text-slate-400 cursor-not-allowed"
|
||||
: "bg-primary text-white hover:bg-primary/90 hover:scale-[1.02] shadow-2xl shadow-primary/30"
|
||||
)}
|
||||
>
|
||||
<Play fill="currentColor" size={28} /> MULAI SESI
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!isTesting}
|
||||
className={cn(
|
||||
"h-24 rounded-[2.5rem] text-xl font-black italic tracking-tighter flex items-center justify-center gap-4 transition-all border-4",
|
||||
!isTesting
|
||||
? "bg-slate-50 text-slate-300 border-slate-100 cursor-not-allowed"
|
||||
: "bg-white border-slate-900 text-slate-900 hover:bg-slate-50 shadow-xl"
|
||||
)}
|
||||
>
|
||||
<Square fill="currentColor" size={28} /> HENTIKAN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Oscilloscope Waveform */}
|
||||
<div className="bg-slate-50 p-10 rounded-[3.5rem] border border-black/5 mt-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 text-primary rounded-lg">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black italic uppercase tracking-tighter">Force Waveform</h3>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-slate-400 tracking-[0.3em]">Live Trace (5.0s)</span>
|
||||
</div>
|
||||
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={forceHistory} margin={{ top: 10, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorForceLive" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(0,0,0,0.03)" />
|
||||
<XAxis hide dataKey="time" />
|
||||
<YAxis
|
||||
hide
|
||||
domain={[0, (dataMax) => Math.max(dataMax, 1000)]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={4}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorForceLive)"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{sessionPeak > 0 && (
|
||||
<ReferenceLine y={sessionPeak} stroke="#ef4444" strokeDasharray="5 5" label={{ position: 'right', value: `Peak`, fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }} />
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Stats */}
|
||||
<div className="space-y-8">
|
||||
{/* History List */}
|
||||
<div className="bg-slate-900 text-white p-10 rounded-[4rem] shadow-2xl min-h-[400px]">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-slate-500">Log Hasil</h3>
|
||||
<div className="flex gap-2">
|
||||
{attempts.length > 0 && !isTesting && (
|
||||
<button
|
||||
onClick={resetAttempts}
|
||||
className="p-2 bg-white/5 hover:bg-red-500/20 text-slate-400 hover:text-red-500 rounded-lg transition-all"
|
||||
title="Reset Riwayat"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
)}
|
||||
<Activity className="text-primary w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{attempts.length === 0 ? (
|
||||
<div className="text-slate-600 italic text-center py-20 bg-white/5 rounded-[3rem] border-2 border-dashed border-white/5">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest">Kosong</p>
|
||||
</div>
|
||||
) : (
|
||||
attempts.map((attempt, idx) => (
|
||||
<div key={idx} className="bg-white/5 group border border-white/5 flex justify-between items-center p-6 rounded-[2rem] transition-all hover:bg-white/10 hover:translate-x-2">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Lari #{attempt.attemptNumber}</span>
|
||||
<div className="text-2xl font-black italic tracking-tighter text-white">
|
||||
{attempt.force} <span className="text-[10px] uppercase text-primary">N</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronUp className="text-primary w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary text-white p-10 rounded-[3.5rem] shadow-2xl shadow-primary/30 text-center relative overflow-hidden group">
|
||||
<div className="relative z-10">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-80">Skor Legenda</span>
|
||||
<div className="text-6xl font-black italic tracking-tighter mt-1">1000<span className="text-xl ml-1">N</span></div>
|
||||
</div>
|
||||
<Target className="absolute -left-4 -bottom-4 w-32 h-32 text-white/10 rotate-12 group-hover:rotate-0 transition-transform duration-1000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveTest;
|
||||
|
|
@ -0,0 +1,578 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { rtdb } from '../firebase';
|
||||
import { ref, onValue, set, off } from 'firebase/database';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Activity,
|
||||
Play,
|
||||
Square,
|
||||
Target,
|
||||
Waves,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
RotateCcw
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine
|
||||
} from 'recharts';
|
||||
|
||||
const GRAPH_BARS = 50; // Jarak diperlebar (dari sebelumnya 150 titik menjadi 50 titik)
|
||||
|
||||
const Monitor = () => {
|
||||
const [athletes, setAthletes] = useState([]);
|
||||
const [selectedAthlete, setSelectedAthlete] = useState('');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [livePeak, setLivePeak] = useState(0);
|
||||
const [liveForce, setLiveForce] = useState(0);
|
||||
const [attempts, setAttempts] = useState([]);
|
||||
const [forceHistory, setForceHistory] = useState(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
|
||||
const [currentAttemptIndex, setCurrentAttemptIndex] = useState(0);
|
||||
const [animatedValue, setAnimatedValue] = useState(0);
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||
const [strikeType, setStrikeType] = useState('pukulan');
|
||||
|
||||
// Track live peak locally for immediate responsiveness
|
||||
const localPeakRef = useRef(0);
|
||||
const isTestingRef = useRef(false); // Ref untuk sinkronisasi dengan listener Firebase
|
||||
|
||||
// 1. Fetch Athletes
|
||||
useEffect(() => {
|
||||
const r = ref(rtdb, 'users');
|
||||
const unsub = onValue(r, (snap) => {
|
||||
const data = snap.val();
|
||||
if (data) {
|
||||
const arr = Object.keys(data).map(key => ({
|
||||
id: String(key),
|
||||
name: String(data[key]?.profile?.name || data[key]?.name || 'Unknown'),
|
||||
}));
|
||||
setAthletes(arr);
|
||||
}
|
||||
});
|
||||
return () => off(r, 'value', unsub);
|
||||
}, []);
|
||||
|
||||
// 2. Load History (Current Session)
|
||||
useEffect(() => {
|
||||
// Reset seketika saat atlet berubah agar tidak ada sisa data atlet sebelumnya
|
||||
setLiveForce(0);
|
||||
setLivePeak(0);
|
||||
setAnimatedValue(0);
|
||||
setForceHistory(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
|
||||
|
||||
if (!selectedAthlete) {
|
||||
setAttempts([]);
|
||||
setCurrentSessionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = ref(rtdb, `test_history/${selectedAthlete}`);
|
||||
const unsub = onValue(r, (snap) => {
|
||||
const data = snap.val();
|
||||
if (data && typeof data === 'object') {
|
||||
// Find all sessions or legacy attempts
|
||||
const sessionKeys = Object.keys(data).filter(k => k.startsWith('session_')).sort().reverse();
|
||||
|
||||
// If there are sessions, use the latest one by default (unless we just started a new one)
|
||||
// If no sessions but there are legacy attempt_ keys, treat them as a "Legacy" session
|
||||
let targetSessionData = null;
|
||||
let activeSessionId = currentSessionId;
|
||||
|
||||
if (!activeSessionId) {
|
||||
if (sessionKeys.length > 0) {
|
||||
activeSessionId = sessionKeys[0];
|
||||
} else if (Object.keys(data).some(k => k.startsWith('attempt_'))) {
|
||||
activeSessionId = 'legacy';
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSessionId === 'legacy') {
|
||||
targetSessionData = data;
|
||||
} else if (activeSessionId) {
|
||||
targetSessionData = data[activeSessionId];
|
||||
}
|
||||
|
||||
setCurrentSessionId(activeSessionId);
|
||||
|
||||
if (targetSessionData) {
|
||||
const arr = [];
|
||||
Object.keys(targetSessionData).forEach(k => {
|
||||
if (k.startsWith('attempt_')) {
|
||||
const attemptNum = parseInt(k.replace('attempt_', ''));
|
||||
arr.push({
|
||||
number: attemptNum,
|
||||
peak: Number(targetSessionData[k]?.peak_newton || 0)
|
||||
});
|
||||
}
|
||||
});
|
||||
arr.sort((a, b) => a.number - b.number);
|
||||
setAttempts(arr);
|
||||
} else {
|
||||
setAttempts([]);
|
||||
}
|
||||
} else {
|
||||
setAttempts([]);
|
||||
setCurrentSessionId(null);
|
||||
}
|
||||
});
|
||||
return () => off(r, 'value', unsub);
|
||||
}, [selectedAthlete, currentSessionId]);
|
||||
|
||||
// 3. Real-time Listeners
|
||||
useEffect(() => {
|
||||
const sRef = ref(rtdb, 'system/test_status');
|
||||
const lRef = ref(rtdb, 'realtime/current_force');
|
||||
const pRef = ref(rtdb, 'realtime/peak_force');
|
||||
|
||||
const unsubS = onValue(sRef, (s) => {
|
||||
const status = s.val();
|
||||
const testing = status === 'measuring' || status === 'waiting';
|
||||
setIsTesting(testing);
|
||||
isTestingRef.current = testing; // Update ref juga
|
||||
if (!testing) {
|
||||
// Test selesai, reset local peak
|
||||
localPeakRef.current = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const unsubL = onValue(lRef, (s) => {
|
||||
const val = Number(s.val() || 0);
|
||||
setLiveForce(val);
|
||||
// Update local peak tracking
|
||||
if (val > localPeakRef.current) {
|
||||
localPeakRef.current = val;
|
||||
}
|
||||
// Rolling graph history (Muncul dari kiri ke kanan)
|
||||
setForceHistory(prev => {
|
||||
const newData = { time: 0, value: val };
|
||||
// Masukkan data baru di awal [newData], buang yang terakhir di belakang
|
||||
const updated = [newData, ...prev.slice(0, -1)];
|
||||
// Maintain indices for re-rendering
|
||||
return updated.map((d, idx) => ({ ...d, time: idx }));
|
||||
});
|
||||
});
|
||||
|
||||
const unsubP = onValue(pRef, (s) => setLivePeak(Number(s.val() || 0)));
|
||||
|
||||
return () => {
|
||||
off(sRef, 'value', unsubS);
|
||||
off(lRef, 'value', unsubL);
|
||||
off(pRef, 'value', unsubP);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 4. Count-up Animation Logic
|
||||
useEffect(() => {
|
||||
if (!isTesting) {
|
||||
// Jangan reset ke 0 agar angka tetap stay di layar setelah selesai
|
||||
return;
|
||||
}
|
||||
|
||||
let animationFrame;
|
||||
const animate = () => {
|
||||
setAnimatedValue(prev => {
|
||||
if (prev < livePeak) {
|
||||
// Cepat bertambah jika selisih besar, melambat jika dekat
|
||||
const diff = livePeak - prev;
|
||||
const step = Math.max(1, Math.ceil(diff / 5)); // "urut" atau sequential cepat
|
||||
return Math.min(livePeak, prev + step);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [livePeak, isTesting]);
|
||||
|
||||
const startTest = async () => {
|
||||
if (!selectedAthlete) return;
|
||||
|
||||
// Cek apakah sesi saat ini sudah penuh (3 percobaan selesai)
|
||||
const isSessionFull = attempts.length >= 3 && attempts.every(a => a.peak > 0);
|
||||
let sessionId = currentSessionId;
|
||||
|
||||
// Mulai sesi baru jika sesi lama penuh atau belum ada sesi/Legacy
|
||||
if (!sessionId || isSessionFull || sessionId === 'legacy') {
|
||||
sessionId = `session_${new Date().getTime()}`;
|
||||
setCurrentSessionId(sessionId);
|
||||
setAttempts([]); // Reset UI lokal seketika
|
||||
}
|
||||
|
||||
// Temukan slot pertama (1, 2, atau 3) yang masih kosong di sesi ini
|
||||
const firstEmptySlot = [1, 2, 3].find(num => {
|
||||
const found = attempts.find(a => a.number === num);
|
||||
return !found || found.peak <= 0;
|
||||
}) || 1;
|
||||
|
||||
const nextId = firstEmptySlot;
|
||||
setCurrentAttemptIndex(nextId - 1);
|
||||
|
||||
console.log(`Starting test for ${selectedAthlete} in session ${sessionId} slot ${nextId}`);
|
||||
|
||||
// Reset semua nilai sebelum sesi baru
|
||||
localPeakRef.current = 0;
|
||||
await set(ref(rtdb, 'realtime/current_force'), 0);
|
||||
await set(ref(rtdb, 'realtime/peak_force'), 0);
|
||||
setLivePeak(0);
|
||||
setLiveForce(0);
|
||||
setAnimatedValue(0);
|
||||
setForceHistory(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
|
||||
|
||||
// Trik: UID diisi path 'athleteId/sessionId' agar firmware menyimpan ke folder tersebut
|
||||
await set(ref(rtdb, 'test_command'), `${selectedAthlete}/${sessionId}|${nextId}|${strikeType}`);
|
||||
};
|
||||
|
||||
const stopTest = async () => {
|
||||
await set(ref(rtdb, 'system/test_status'), 'idle');
|
||||
await set(ref(rtdb, 'test_command'), '');
|
||||
};
|
||||
|
||||
const resetAttempts = async () => {
|
||||
if (!selectedAthlete || isTesting) return;
|
||||
|
||||
if (currentSessionId && currentSessionId !== 'legacy') {
|
||||
// Hanya hapus sesi yang aktif saat ini, jangan hapus seluruh riwayat atlet
|
||||
await set(ref(rtdb, `test_history/${selectedAthlete}/${currentSessionId}`), null);
|
||||
} else {
|
||||
await set(ref(rtdb, `test_history/${selectedAthlete}`), null);
|
||||
}
|
||||
|
||||
setAttempts([]);
|
||||
};
|
||||
|
||||
const currentAthlete = athletes.find(a => a.id === selectedAthlete);
|
||||
|
||||
// Display values
|
||||
// PERBAIKAN: Jika atlet belum dipilih, paksa angka ke 0. Jika sudah, gunakan logika persistence.
|
||||
const latestAttemptPeak = attempts.length > 0 ? attempts[attempts.length - 1].peak : 0;
|
||||
|
||||
const displayForce = !selectedAthlete ? 0 : (isTesting ? animatedValue : (liveForce > 0 ? liveForce : latestAttemptPeak));
|
||||
const displayPeak = !selectedAthlete ? 0 : (isTesting ? livePeak : (livePeak > 0 ? livePeak : latestAttemptPeak));
|
||||
|
||||
// Dynamic scale for graph
|
||||
const maxGraph = Math.max(...forceHistory.map(d => d.value), 100);
|
||||
|
||||
// Font size based on digit count
|
||||
const gaugeFontSize = displayForce >= 10000
|
||||
? 'text-[7rem]'
|
||||
: displayForce >= 1000
|
||||
? 'text-[10rem]'
|
||||
: displayForce >= 100
|
||||
? 'text-[13rem]'
|
||||
: 'text-[16rem]';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row gap-8 animate-in fade-in duration-500">
|
||||
|
||||
{/* ─── Sidebar Kiri: SESI LATIHAN ────────────────────────── */}
|
||||
<div className="w-full xl:w-96 space-y-6">
|
||||
<div className="glass-card rounded-[3rem] overflow-hidden border-none shadow-2xl relative">
|
||||
<div className="h-2 w-full bg-slate-900 absolute top-0 left-0" />
|
||||
<div className="p-8 pt-10">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center text-primary shadow-lg">
|
||||
<Activity size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black italic uppercase tracking-tighter">Sesi Latihan</h2>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none">Konfigurasi audit performa</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Pilih Atlet */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Pilih Subjek</label>
|
||||
<select
|
||||
value={selectedAthlete}
|
||||
onChange={(e) => setSelectedAthlete(e.target.value)}
|
||||
disabled={isTesting}
|
||||
className="w-full h-14 bg-slate-50 border border-black/5 rounded-2xl px-4 font-bold text-slate-900 outline-none focus:border-primary transition-all appearance-none cursor-pointer"
|
||||
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\' /%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
|
||||
>
|
||||
<option value="">Pilih Atlet dari Daftar</option>
|
||||
{athletes.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Pilih Jenis Serangan */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Jenis Serangan</label>
|
||||
<div className="flex p-1 bg-slate-100 rounded-2xl gap-1">
|
||||
<button
|
||||
onClick={() => setStrikeType('pukulan')}
|
||||
disabled={isTesting}
|
||||
className={cn(
|
||||
"flex-1 h-12 rounded-xl font-bold text-xs transition-all uppercase tracking-widest",
|
||||
strikeType === 'pukulan'
|
||||
? "bg-white text-primary shadow-sm"
|
||||
: "text-slate-400 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Pukulan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStrikeType('tendangan')}
|
||||
disabled={isTesting}
|
||||
className={cn(
|
||||
"flex-1 h-12 rounded-xl font-bold text-xs transition-all uppercase tracking-widest",
|
||||
strikeType === 'tendangan'
|
||||
? "bg-white text-primary shadow-sm"
|
||||
: "text-slate-400 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Tendangan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tombol Mulai / Hentikan */}
|
||||
{!isTesting ? (
|
||||
<button
|
||||
onClick={startTest}
|
||||
disabled={!selectedAthlete}
|
||||
className="w-full h-20 bg-primary text-white rounded-[1.8rem] font-black text-lg italic shadow-2xl shadow-primary/30 active:scale-95 transition-all disabled:opacity-30 flex items-center justify-center gap-3"
|
||||
>
|
||||
<Play fill="currentColor" size={20} /> MULAI SESI
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopTest}
|
||||
className="w-full h-20 bg-red-500 text-white rounded-[1.8rem] font-black text-lg italic shadow-2xl shadow-red-500/30 animate-pulse active:scale-95 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<Square fill="currentColor" size={20} /> HENTIKAN
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Logging Percobaan */}
|
||||
<div className="pt-8 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Logging Percobaan</p>
|
||||
{attempts.length > 0 && !isTesting && (
|
||||
<button
|
||||
onClick={resetAttempts}
|
||||
className="flex items-center gap-1 text-[9px] font-black uppercase text-red-400 hover:text-red-600 tracking-widest transition-colors"
|
||||
title="Reset semua percobaan"
|
||||
>
|
||||
<RotateCcw size={10} /> Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{[0, 1, 2].map(i => {
|
||||
const isActive = isTesting && currentAttemptIndex === i;
|
||||
const attemptData = attempts[i];
|
||||
const hasData = attemptData !== undefined;
|
||||
|
||||
// Nilai yang ditampilkan:
|
||||
// - Jika sedang aktif (testing): tampilkan animatedValue (count-up)
|
||||
// - Jika sudah selesai: tampilkan peak dari history
|
||||
// - Jika belum: tampilkan 0
|
||||
const displayVal = isActive
|
||||
? animatedValue
|
||||
: (hasData ? attemptData.peak : 0);
|
||||
|
||||
return (
|
||||
<div key={i} className={cn(
|
||||
"h-20 rounded-[1.8rem] flex items-center justify-between px-6 border border-black/5 bg-slate-50 transition-all",
|
||||
isActive && "bg-white shadow-xl ring-2 ring-primary/20 scale-[1.02]"
|
||||
)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn("w-10 h-10 rounded-full flex items-center justify-center font-black text-[10px]", isActive ? "bg-primary text-white" : "bg-white text-slate-300 shadow-inner")}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] font-black uppercase text-slate-400 tracking-widest leading-none mb-1">Attempt {i + 1}</span>
|
||||
<span className={cn("text-[10px] font-black uppercase italic", isActive ? "text-primary" : "text-slate-300")}>
|
||||
{isActive ? "Recording..." : (hasData ? "Finished" : "Ready")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-2xl font-black italic transition-all",
|
||||
isActive ? "text-primary animate-pulse" : (hasData ? "text-slate-900" : "text-slate-200")
|
||||
)}>
|
||||
{displayVal > 0 ? Number(displayVal).toFixed(0) : "0"} <span className="text-[10px]">N</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Main: Center + Right ──────────────────────────────── */}
|
||||
<div className="flex-1 space-y-8">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* CENTER GAUGE */}
|
||||
<div className="flex-1 glass-card rounded-[4rem] p-12 shadow-2xl flex flex-col items-center justify-center text-center relative overflow-hidden min-h-[500px] border-none">
|
||||
{/* Decorative circles */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[450px] h-[450px] rounded-full border-[1.5px] border-black/[0.03] flex items-center justify-center pointer-events-none">
|
||||
<div className="w-[300px] h-[300px] rounded-full border border-primary/5" />
|
||||
</div>
|
||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 w-40 h-[1.5px] bg-slate-100" />
|
||||
|
||||
<Badge className="mb-6 px-6 py-2 rounded-full font-black text-[9px] tracking-[0.3em] uppercase bg-primary/5 text-primary border-primary/10">
|
||||
{isTesting ? "Sensor Active Tracking" : "Sensor Standby Mode"}
|
||||
</Badge>
|
||||
|
||||
<h2 className="text-slate-400 text-[11px] font-black uppercase tracking-[0.4em] mb-2">Gaya Saat Ini</h2>
|
||||
|
||||
{/* ANGKA NEWTON */}
|
||||
<div className="flex flex-col items-center justify-center gap-0 relative z-10">
|
||||
<span className={cn(
|
||||
"font-black leading-none italic select-none tracking-tighter",
|
||||
"transition-colors duration-75",
|
||||
displayForce > 0 ? "text-slate-700" : "text-slate-100",
|
||||
gaugeFontSize
|
||||
)}>
|
||||
{displayForce.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-5xl font-black text-primary italic tracking-tighter -mt-8">NEWTON</span>
|
||||
</div>
|
||||
|
||||
{/* Badge Peak */}
|
||||
<div className={cn(
|
||||
"mt-5 flex items-center gap-2 px-5 py-2 rounded-full transition-all duration-300",
|
||||
isTesting ? "bg-emerald-50 opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<TrendingUp size={13} className="text-emerald-500" />
|
||||
<span className="text-[11px] font-black uppercase text-emerald-600 tracking-widest">
|
||||
Peak: {displayPeak.toFixed(0)} N
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-12 mt-8 w-full max-w-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-emerald-500 tracking-widest">
|
||||
<Target size={14} /> Target: {">"}800N
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-primary tracking-widest border-l border-slate-100 pl-12">
|
||||
<Waves size={14} /> Athlete: {currentAthlete?.name || "Pilih Atlet"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT ANALYSIS CARD */}
|
||||
<div className="w-full lg:w-[400px] space-y-6">
|
||||
<div className="glass-card rounded-[3.5rem] p-8 border-none shadow-2xl h-full">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-emerald-50 text-emerald-500 flex items-center justify-center shadow-inner">
|
||||
<Zap size={22} />
|
||||
</div>
|
||||
<h3 className="text-xs font-black uppercase tracking-widest italic">Analisis Dampak</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 mt-10">
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Efisiensi Serangan</span>
|
||||
<span className="text-lg font-black italic text-emerald-500">
|
||||
{(Math.min(100, displayPeak / 1000 * 100)).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-50 rounded-full p-[2px] shadow-inner overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (displayPeak / 1000) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-[9px] font-black uppercase text-slate-300 tracking-widest">
|
||||
<span>0 N</span>
|
||||
<span>1000 N</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:rotate-12 transition-transform">
|
||||
<MessageSquare size={100} />
|
||||
</div>
|
||||
<span className="text-[9px] font-black uppercase text-primary tracking-widest border-b border-primary/20 pb-2 mb-4 block">Saran Pelatih</span>
|
||||
<p className="text-sm font-bold italic leading-relaxed text-slate-300">
|
||||
"Fokus pada kecepatan putaran pinggang untuk menambah gaya ledak."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM — Live Force Stream Graph */}
|
||||
<div className="glass-card rounded-[3.5rem] p-10 border-none shadow-2xl min-h-[300px] relative overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-8 relative z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-50 text-red-500 rounded-lg">
|
||||
<Waves size={20} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black italic uppercase tracking-tighter">Live Force Stream</h3>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Osiloskop gaya real-time dari sensor.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-500 rounded-full">
|
||||
<div className={cn("w-2 h-2 rounded-full bg-red-500", isTesting && "animate-pulse")} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">
|
||||
{isTesting ? "Live" : "Idle"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Oscilloscope Waveform */}
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={forceHistory} margin={{ top: 10, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorForce" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(0,0,0,0.03)" />
|
||||
<XAxis hide dataKey="time" />
|
||||
<YAxis
|
||||
hide
|
||||
domain={[0, maxGraph]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorForce)"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{isTesting && (
|
||||
<ReferenceLine y={livePeak} stroke="#ef4444" strokeDasharray="3 3" label={{ position: 'right', value: `Peak: ${livePeak}N`, fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }} />
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6 text-[9px] font-black uppercase text-slate-300 tracking-widest px-2">
|
||||
<span>Timeline (5s)</span>
|
||||
<span>{(maxGraph / 2).toFixed(0)} N</span>
|
||||
<span>Max Scale: {maxGraph.toFixed(0)} N</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 w-full h-[1.5px] bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitor;
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { rtdb } from '../firebase';
|
||||
import { ref, onValue, set, get } from 'firebase/database';
|
||||
import { Radio, Scan, Save, User as UserIcon, Calendar, Ruler, Weight, Tag, Activity } from 'lucide-react';
|
||||
import { Card, CardContent, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const CATEGORIES = ["Usia dini A", "Usia dini B", "Pra remaja", "Remaja", "Dewasa"];
|
||||
|
||||
const Register = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
age: '',
|
||||
height: '',
|
||||
weight: '',
|
||||
category: 'Usia dini A',
|
||||
rfid_tag: ''
|
||||
});
|
||||
|
||||
const [scanning, setScanning] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rfidError, setRfidError] = useState('');
|
||||
|
||||
// ================= RFID LISTENER =================
|
||||
useEffect(() => {
|
||||
const scannedRef = ref(rtdb, 'system/last_scanned');
|
||||
|
||||
const unsubscribe = onValue(scannedRef, async (snapshot) => {
|
||||
const val = snapshot.val();
|
||||
if (val && typeof val === 'string') {
|
||||
try {
|
||||
const userRef = ref(rtdb, `users/${val}`);
|
||||
const userSnap = await get(userRef);
|
||||
|
||||
if (userSnap.exists()) {
|
||||
setRfidError("UID sudah terdaftar!");
|
||||
setFormData(prev => ({ ...prev, rfid_tag: '' }));
|
||||
setScanning(true);
|
||||
|
||||
await set(scannedRef, ""); // Reset system/last_scanned
|
||||
|
||||
// Clear error message after 3 seconds
|
||||
setTimeout(() => setRfidError(''), 3000);
|
||||
} else {
|
||||
setRfidError('');
|
||||
setFormData(prev => ({ ...prev, rfid_tag: val }));
|
||||
setScanning(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking UID:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// ================= HANDLE INPUT =================
|
||||
const handleChange = (e) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
// ================= SUBMIT =================
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.rfid_tag) {
|
||||
alert('Silakan scan kartu RFID terlebih dahulu.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const uid = formData.rfid_tag;
|
||||
|
||||
const payload = {
|
||||
...formData,
|
||||
id: uid,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const userRef = ref(rtdb, `users/${uid}`);
|
||||
|
||||
await set(userRef, {
|
||||
profile: payload,
|
||||
total_tests: 0,
|
||||
best_power: 0
|
||||
});
|
||||
|
||||
// 🔥 RESET RFID (PENTING)
|
||||
await set(ref(rtdb, "system/last_scanned"), "");
|
||||
|
||||
alert('✅ Data berhasil disimpan');
|
||||
|
||||
// reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
age: '',
|
||||
height: '',
|
||||
weight: '',
|
||||
category: 'Usia dini A',
|
||||
rfid_tag: ''
|
||||
});
|
||||
|
||||
setScanning(true);
|
||||
|
||||
navigate('/');
|
||||
|
||||
} catch (err) {
|
||||
alert('❌ Gagal: ' + err.message);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<Card className="w-full max-w-3xl rounded-[3rem] shadow-2xl overflow-hidden">
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="bg-slate-900 text-white p-10">
|
||||
<CardTitle className="text-3xl font-black flex items-center gap-3">
|
||||
<Scan /> PENDAFTARAN ATLET
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400 mt-2">
|
||||
Integrasi RFID & Sistem ImpactMonitor
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-10 space-y-10">
|
||||
|
||||
{/* IDENTITAS */}
|
||||
<div>
|
||||
<Label>Nama</Label>
|
||||
<Input name="name" value={formData.name} onChange={handleChange} required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Umur</Label>
|
||||
<Input type="number" min="1" name="age" value={formData.age} onChange={handleChange} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Kategori</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(val) => setFormData(prev => ({ ...prev, category: val }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* FISIK */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Tinggi</Label>
|
||||
<Input type="number" min="1" name="height" value={formData.height} onChange={handleChange} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Berat</Label>
|
||||
<Input type="number" min="1" name="weight" value={formData.weight} onChange={handleChange} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* RFID */}
|
||||
<div className={`p-8 border-4 border-dashed text-center rounded-2xl transition-colors
|
||||
${rfidError ? 'border-red-500 bg-red-100 text-red-700' :
|
||||
(scanning ? 'border-blue-300 animate-pulse' : 'border-green-500 bg-green-100')}
|
||||
`}>
|
||||
<Radio className={`mx-auto mb-3 ${rfidError ? 'text-red-500' : ''}`} size={40} />
|
||||
<p className={`font-bold text-lg ${rfidError ? 'text-red-700' : ''}`}>
|
||||
{rfidError ? 'UID SUDAH TERDAFTAR' : (formData.rfid_tag || 'DEKATKAN KARTU')}
|
||||
</p>
|
||||
<p className={`text-sm ${rfidError ? 'text-red-600' : 'text-gray-500'}`}>
|
||||
{rfidError ? 'Gunakan kartu lain' : (scanning ? 'Menunggu scan...' : 'RFID terdeteksi')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BUTTON */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || scanning || !formData.name || !formData.age || !formData.height || !formData.weight}
|
||||
>
|
||||
{loading ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Settings as SettingsIcon, ShieldCheck, KeyRound, AlertCircle, Users, UserPlus, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
const Settings = () => {
|
||||
const { user } = useAuth();
|
||||
const [passwords, setPasswords] = useState({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const [newAdmin, setNewAdmin] = useState({ username: '', password: '' });
|
||||
const [adminUsers, setAdminUsers] = useState([]);
|
||||
const [status, setStatus] = useState({ type: '', message: '' });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminUsers();
|
||||
}, []);
|
||||
|
||||
const fetchAdminUsers = async () => {
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${API_URL}/api/admin/users`);
|
||||
const data = await response.json();
|
||||
setAdminUsers(data);
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil daftar admin');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
if (passwords.newPassword !== passwords.confirmPassword) {
|
||||
setStatus({ type: 'error', message: 'Konfirmasi password tidak cocok' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStatus({ type: '', message: '' });
|
||||
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${API_URL}/api/admin/update-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: user?.username,
|
||||
oldPassword: passwords.oldPassword,
|
||||
newPassword: passwords.newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setStatus({ type: 'success', message: 'Password berhasil diperbarui!' });
|
||||
setPasswords({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} else {
|
||||
setStatus({ type: 'error', message: data.message || 'Gagal memperbarui password' });
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({ type: 'error', message: 'Koneksi ke server gagal' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAdmin = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${API_URL}/api/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newAdmin)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setNewAdmin({ username: '', password: '' });
|
||||
fetchAdminUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Gagal menambah admin');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAdmin = async (id) => {
|
||||
if (adminUsers.length <= 1) return alert('Minimal harus ada 1 admin!');
|
||||
if (!confirm('Hapus admin ini?')) return;
|
||||
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
await fetch(`${API_URL}/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
fetchAdminUsers();
|
||||
} catch (error) {
|
||||
console.error('Gagal menghapus admin');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||
<SettingsIcon className="text-primary w-8 h-8" />
|
||||
Pengaturan Sistem
|
||||
</h1>
|
||||
<p className="text-slate-500">Kelola keamanan dan pengguna admin aplikasi Impact Monitor.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{/* Bagian Ganti Password */}
|
||||
<Card className="border-black/5 shadow-xl shadow-slate-200/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
Ganti Password Saya
|
||||
</CardTitle>
|
||||
<CardDescription>Login sebagai: <span className="font-bold text-slate-900">{user?.username}</span></CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdatePassword}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="oldPassword">Password Lama</Label>
|
||||
<Input
|
||||
id="oldPassword"
|
||||
type="password"
|
||||
value={passwords.oldPassword}
|
||||
onChange={(e) => setPasswords({...passwords, oldPassword: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">Password Baru</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={passwords.newPassword}
|
||||
onChange={(e) => setPasswords({...passwords, newPassword: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Konfirmasi Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={passwords.confirmPassword}
|
||||
onChange={(e) => setPasswords({...passwords, confirmPassword: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status.message && (
|
||||
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${
|
||||
status.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{status.type === 'error' && <AlertCircle size={16} />}
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Menyimpan...' : 'Perbarui Password'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Manajemen User Admin */}
|
||||
<div className="space-y-8">
|
||||
<Card className="border-black/5 shadow-xl shadow-slate-200/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
Daftar Admin
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{adminUsers.map(admin => (
|
||||
<div key={admin.id} className="flex justify-between items-center p-3 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-200 flex items-center justify-center text-xs font-bold text-slate-600">
|
||||
{admin.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-slate-700">{admin.username}</span>
|
||||
</div>
|
||||
{admin.username !== user?.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteAdmin(admin.id)}
|
||||
className="text-slate-400 hover:text-red-500 rounded-lg"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-black/5 shadow-xl shadow-slate-200/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<UserPlus className="w-5 h-5 text-primary" />
|
||||
Tambah Admin Baru
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleAddAdmin}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username Admin Baru</Label>
|
||||
<Input
|
||||
value={newAdmin.username}
|
||||
onChange={(e) => setNewAdmin({...newAdmin, username: e.target.value})}
|
||||
placeholder="Ketik username baru..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newAdmin.password}
|
||||
onChange={(e) => setNewAdmin({...newAdmin, password: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" variant="secondary" className="w-full">Tambah Admin</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import path from "path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"firebase": "^12.11.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
|
||||
import { initializeApp } from "firebase/app";
|
||||
import { getDatabase, ref, get, child } from "firebase/database";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyDhFtqvaTMgYH9vyAHFclDUXiOxkl-2GqM",
|
||||
authDomain: "silat-b3100.firebaseapp.com",
|
||||
databaseURL: "https://silat-b3100-default-rtdb.firebaseio.com/",
|
||||
projectId: "silat-b3100",
|
||||
storageBucket: "silat-b3100.firebasestorage.app",
|
||||
messagingSenderId: "471266034969",
|
||||
appId: "1:471266034969:web:467de19582cf2fd3da3188"
|
||||
};
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const db = getDatabase(app);
|
||||
|
||||
async function checkData() {
|
||||
try {
|
||||
const snapshot = await get(child(ref(db), 'test_history'));
|
||||
if (snapshot.exists()) {
|
||||
console.log("Test History Sample:");
|
||||
const data = snapshot.val();
|
||||
const firstUser = Object.keys(data)[0];
|
||||
const firstAttempt = Object.keys(data[firstUser])[0];
|
||||
console.log(JSON.stringify(data[firstUser][firstAttempt], null, 2));
|
||||
} else {
|
||||
console.log("No test history found.");
|
||||
}
|
||||
|
||||
const userSnapshot = await get(child(ref(db), 'users'));
|
||||
if (userSnapshot.exists()) {
|
||||
console.log("\nCategories found:");
|
||||
const users = userSnapshot.val();
|
||||
const categories = new Set();
|
||||
Object.values(users).forEach(u => {
|
||||
if (u.profile && u.profile.category) categories.add(u.profile.category);
|
||||
});
|
||||
console.log(Array.from(categories));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
process.exit();
|
||||
}
|
||||
|
||||
checkData();
|
||||
Loading…
Reference in New Issue