upload pertama

This commit is contained in:
fanferr 2026-05-19 23:32:56 +07:00
commit 25ba754fca
56 changed files with 15413 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
backend/node_modules/
frontend/node_modules/
frontend/dist/
backend/silat_monitor.db
.env
.DS_Store

44
backend/database.js Normal file
View File

@ -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;

2875
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backend/package.json Normal file
View File

@ -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"
}
}

265
backend/server.js Normal file
View File

@ -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}`);
});

15
backend/simulate_rfid.js Normal file
View File

@ -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);
});

View File

@ -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
}

101
cleanup_data.js Normal file
View File

@ -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();

75
firmware/calibrate.ino Normal file
View File

@ -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
}

View File

@ -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);
}

363
firmware/impact_monitor.ino Normal file
View File

@ -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();
}
}
}

View File

@ -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();
}
}

24
frontend/.gitignore vendored Normal file
View File

@ -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?

16
frontend/README.md Normal file
View File

@ -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.

BIN
frontend/error-log.txt Normal file

Binary file not shown.

29
frontend/eslint.config.js Normal file
View File

@ -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_]' }],
},
},
])

16
frontend/index.html Normal file
View File

@ -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>

10
frontend/jsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

6289
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

1
frontend/public/vite.svg Normal file
View File

@ -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

42
frontend/src/App.css Normal file
View File

@ -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;
}

119
frontend/src/App.jsx Normal file
View File

@ -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>&copy; {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;

41
frontend/src/api/api.js Normal file
View File

@ -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();
};

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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,
}

View File

@ -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>
);
};

View File

@ -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>
);
};

17
frontend/src/firebase.js Normal file
View File

@ -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 };

167
frontend/src/index.css Normal file
View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

10
frontend/src/main.jsx Normal file
View File

@ -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>,
)

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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")],
}

12
frontend/vite.config.js Normal file
View File

@ -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"),
},
},
})

1058
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"firebase": "^12.11.0"
}
}

47
scratch_check_firebase.js Normal file
View File

@ -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();