Initial commit apps directory with .gitignore

This commit is contained in:
2026-02-22 15:15:41 +08:00
commit 0aa8cdd72c
228 changed files with 69672 additions and 0 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,129 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.test
.env.production
.env.local
.env.development.local
.env.test.local
.env.production.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# OS metadata
.DS_Store
Thumbs.db
# Project specific backups
*.tar.gz

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "buku-induk-siswa",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "node server.js",
"dev:full": "concurrently \"npm run dev:server\" \"npm run dev\"",
"build": "tsc && vite build",
"preview": "vite preview",
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"html2pdf.js": "^0.10.1",
"lucide-react": "^0.560.0",
"mysql2": "^3.12.0",
"pdf-lib": "^1.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.12.7",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^9.2.1",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}

View File

@@ -0,0 +1,795 @@
import express from 'express';
import path from 'path';
import cors from 'cors';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import bcrypt from 'bcryptjs';
import pool, { initDatabase } from './server/db.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3006;
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Helper: Convert camelCase to snake_case
const camelToSnake = (str) => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
const snakeToCamel = (str) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
// Helper: Convert object keys
const convertKeysToSnake = (obj) => {
const result = {};
for (const key in obj) {
result[camelToSnake(key)] = obj[key];
}
return result;
};
const convertKeysToCamel = (obj) => {
const result = {};
for (const key in obj) {
result[snakeToCamel(key)] = obj[key];
}
return result;
};
// ==================== AUTH API ====================
// Login
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username dan password diperlukan' });
}
const [users] = await pool.query('SELECT * FROM users WHERE username = ?', [username]);
if (users.length === 0) {
return res.status(401).json({ success: false, message: 'Username atau password salah' });
}
const user = users[0];
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ success: false, message: 'Username atau password salah' });
}
res.json({
success: true,
user: {
id: user.id,
username: user.username,
fullName: user.full_name,
role: user.role
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: 'Terjadi kesalahan server' });
}
});
// ==================== STUDENTS API ====================
// Get all students
app.get('/api/students', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM students ORDER BY nama ASC');
const students = rows.map(row => {
const student = convertKeysToCamel(row);
// Normalize field names for frontend compatibility
return {
...student,
id: String(student.id),
fotoDiterimaUrl: student.fotoDiterimaUrl || '',
fotoLulusUrl: student.fotoLulusUrl || '',
};
});
res.json(students);
} catch (error) {
console.error('Error fetching students:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data siswa' });
}
});
// Get single student
app.get('/api/students/:id', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM students WHERE id = ?', [req.params.id]);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: 'Siswa tidak ditemukan' });
}
const student = convertKeysToCamel(rows[0]);
student.id = String(student.id);
res.json(student);
} catch (error) {
console.error('Error fetching student:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data siswa' });
}
});
// Create/Update student
app.post('/api/students', async (req, res) => {
try {
const studentData = req.body;
const { id, ...data } = studentData;
// Convert keys to snake_case for database
const dbData = convertKeysToSnake(data);
// Remove undefined/null values and clean up data
const cleanData = {};
for (const [key, value] of Object.entries(dbData)) {
if (value !== undefined && value !== null) {
cleanData[key] = value;
}
}
if (id && id !== '' && !isNaN(parseInt(id))) {
// Update existing student
const fields = Object.keys(cleanData);
const values = Object.values(cleanData);
const setClause = fields.map(f => `${f} = ?`).join(', ');
await pool.query(`UPDATE students SET ${setClause} WHERE id = ?`, [...values, parseInt(id)]);
res.json({ success: true, message: 'Data siswa berhasil diupdate', id: parseInt(id) });
} else {
// Insert new student
const fields = Object.keys(cleanData);
const values = Object.values(cleanData);
const placeholders = fields.map(() => '?').join(', ');
const [result] = await pool.query(
`INSERT INTO students (${fields.join(', ')}) VALUES (${placeholders})`,
values
);
res.json({ success: true, message: 'Data siswa berhasil disimpan', id: result.insertId });
}
} catch (error) {
console.error('Error saving student:', error);
res.status(500).json({ success: false, message: 'Gagal menyimpan data siswa: ' + error.message });
}
});
// Delete student
app.delete('/api/students/:id', async (req, res) => {
try {
const [result] = await pool.query('DELETE FROM students WHERE id = ?', [req.params.id]);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Siswa tidak ditemukan' });
}
res.json({ success: true, message: 'Data siswa berhasil dihapus' });
} catch (error) {
console.error('Error deleting student:', error);
res.status(500).json({ success: false, message: 'Gagal menghapus data siswa' });
}
});
// ==================== SETTINGS API ====================
// Get settings
app.get('/api/settings', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']);
if (rows.length === 0) {
res.json({
schoolName: 'SMA NEGERI 1 ABIANSEMAL',
logoUrl: 'https://iili.io/KN7pUR2.png',
faviconUrl: '',
tahunAjaran: '2025/2026',
margins: { top: 20, right: 20, bottom: 20, left: 20 }
});
} else {
res.json(JSON.parse(rows[0].setting_value));
}
} catch (error) {
console.error('Error fetching settings:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil pengaturan' });
}
});
// Save settings
app.post('/api/settings', async (req, res) => {
try {
const settings = req.body;
const [existing] = await pool.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']);
if (existing.length === 0) {
await pool.query('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)',
['app_settings', JSON.stringify(settings)]);
} else {
await pool.query('UPDATE settings SET setting_value = ? WHERE setting_key = ?',
[JSON.stringify(settings), 'app_settings']);
}
res.json({ success: true, message: 'Pengaturan berhasil disimpan' });
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ success: false, message: 'Gagal menyimpan pengaturan' });
}
});
// ==================== STATISTICS API ====================
// Get student statistics
app.get('/api/stats', async (req, res) => {
try {
const [total] = await pool.query('SELECT COUNT(*) as count FROM students');
const [laki] = await pool.query("SELECT COUNT(*) as count FROM students WHERE jenis_kelamin = 'L'");
const [perempuan] = await pool.query("SELECT COUNT(*) as count FROM students WHERE jenis_kelamin = 'P'");
const [byTahun] = await pool.query(
'SELECT tahun_ajaran, COUNT(*) as count FROM students GROUP BY tahun_ajaran ORDER BY tahun_ajaran DESC'
);
res.json({
total: total[0].count,
laki: laki[0].count,
perempuan: perempuan[0].count,
byTahunAjaran: byTahun
});
} catch (error) {
console.error('Error fetching stats:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil statistik' });
}
});
// ==================== LEGER API ====================
// Get all legers for a student
app.get('/api/students/:id/legers', async (req, res) => {
try {
const studentId = req.params.id;
const [rows] = await pool.query(
'SELECT id, student_id, nis, nama, semester, file_name, file_size, uploaded_at, updated_at FROM student_legers WHERE student_id = ? ORDER BY semester ASC',
[studentId]
);
res.json(rows);
} catch (error) {
console.error('Error fetching legers:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data leger' });
}
});
// Get single leger with file data (for download/view)
app.get('/api/students/:id/legers/:semester', async (req, res) => {
try {
const { id, semester } = req.params;
const [rows] = await pool.query(
'SELECT * FROM student_legers WHERE student_id = ? AND semester = ?',
[id, semester]
);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: 'Leger tidak ditemukan' });
}
res.json(rows[0]);
} catch (error) {
console.error('Error fetching leger:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data leger' });
}
});
// Upload/Update leger
app.post('/api/students/:id/legers', async (req, res) => {
try {
const studentId = req.params.id;
const { semester, fileName, fileData, fileSize, nis, nama } = req.body;
if (!semester || !fileName || !fileData) {
return res.status(400).json({ success: false, message: 'Data tidak lengkap' });
}
// Check if leger already exists for this student and semester
const [existing] = await pool.query(
'SELECT id FROM student_legers WHERE student_id = ? AND semester = ?',
[studentId, semester]
);
if (existing.length > 0) {
// Update existing
await pool.query(
'UPDATE student_legers SET file_name = ?, file_data = ?, file_size = ?, nis = ?, nama = ?, updated_at = NOW() WHERE student_id = ? AND semester = ?',
[fileName, fileData, fileSize, nis, nama, studentId, semester]
);
res.json({ success: true, message: `Leger semester ${semester} berhasil diperbarui` });
} else {
// Insert new
await pool.query(
'INSERT INTO student_legers (student_id, nis, nama, semester, file_name, file_data, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)',
[studentId, nis, nama, semester, fileName, fileData, fileSize]
);
res.json({ success: true, message: `Leger semester ${semester} berhasil diupload` });
}
} catch (error) {
console.error('Error uploading leger:', error);
res.status(500).json({ success: false, message: 'Gagal mengupload leger: ' + error.message });
}
});
// Delete leger
app.delete('/api/students/:id/legers/:semester', async (req, res) => {
try {
const { id, semester } = req.params;
const [result] = await pool.query(
'DELETE FROM student_legers WHERE student_id = ? AND semester = ?',
[id, semester]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Leger tidak ditemukan' });
}
res.json({ success: true, message: `Leger semester ${semester} berhasil dihapus` });
} catch (error) {
console.error('Error deleting leger:', error);
res.status(500).json({ success: false, message: 'Gagal menghapus leger' });
}
});
// Export all legers for a student as JSON (for backup)
app.get('/api/students/:id/legers/export/all', async (req, res) => {
try {
const studentId = req.params.id;
const [rows] = await pool.query(
'SELECT * FROM student_legers WHERE student_id = ? ORDER BY semester ASC',
[studentId]
);
res.json({
success: true,
studentId,
legers: rows
});
} catch (error) {
console.error('Error exporting legers:', error);
res.status(500).json({ success: false, message: 'Gagal mengekspor leger' });
}
});
// ==================== DOCUMENTS API ====================
// Get all documents for a student (optionally filter by type)
app.get('/api/students/:id/documents', async (req, res) => {
try {
const studentId = req.params.id;
const docType = req.query.type;
let query = 'SELECT id, student_id, nis, nama, doc_type, doc_name, file_name, file_size, uploaded_at, updated_at FROM student_documents WHERE student_id = ?';
const params = [studentId];
if (docType) {
query += ' AND doc_type = ?';
params.push(docType);
}
query += ' ORDER BY uploaded_at DESC';
const [rows] = await pool.query(query, params);
res.json(rows);
} catch (error) {
console.error('Error fetching documents:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data dokumen' });
}
});
// Get single document with file data
app.get('/api/students/:id/documents/:docId', async (req, res) => {
try {
const { id, docId } = req.params;
const [rows] = await pool.query(
'SELECT * FROM student_documents WHERE student_id = ? AND id = ?',
[id, docId]
);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: 'Dokumen tidak ditemukan' });
}
res.json(rows[0]);
} catch (error) {
console.error('Error fetching document:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data dokumen' });
}
});
// Upload document
app.post('/api/students/:id/documents', async (req, res) => {
try {
const studentId = req.params.id;
const { docType, docName, fileName, fileData, fileSize, nis, nama } = req.body;
if (!docType || !fileName || !fileData) {
return res.status(400).json({ success: false, message: 'Data tidak lengkap' });
}
// For ijazah, check if one already exists (only allow 1 ijazah per student)
if (docType === 'ijazah') {
const [existing] = await pool.query(
'SELECT id FROM student_documents WHERE student_id = ? AND doc_type = ?',
[studentId, 'ijazah']
);
if (existing.length > 0) {
// Update existing ijazah
await pool.query(
'UPDATE student_documents SET doc_name = ?, file_name = ?, file_data = ?, file_size = ?, nis = ?, nama = ?, updated_at = NOW() WHERE student_id = ? AND doc_type = ?',
[docName || 'Ijazah', fileName, fileData, fileSize, nis, nama, studentId, 'ijazah']
);
return res.json({ success: true, message: 'Ijazah berhasil diperbarui' });
}
}
// Insert new document
await pool.query(
'INSERT INTO student_documents (student_id, nis, nama, doc_type, doc_name, file_name, file_data, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[studentId, nis, nama, docType, docName || (docType === 'ijazah' ? 'Ijazah' : 'Sertifikat'), fileName, fileData, fileSize]
);
const docLabel = docType === 'ijazah' ? 'Ijazah' : 'Sertifikat';
res.json({ success: true, message: `${docLabel} berhasil diupload` });
} catch (error) {
console.error('Error uploading document:', error);
res.status(500).json({ success: false, message: 'Gagal mengupload dokumen: ' + error.message });
}
});
// Delete document
app.delete('/api/students/:id/documents/:docId', async (req, res) => {
try {
const { id, docId } = req.params;
const [result] = await pool.query(
'DELETE FROM student_documents WHERE student_id = ? AND id = ?',
[id, docId]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Dokumen tidak ditemukan' });
}
res.json({ success: true, message: 'Dokumen berhasil dihapus' });
} catch (error) {
console.error('Error deleting document:', error);
res.status(500).json({ success: false, message: 'Gagal menghapus dokumen' });
}
});
// ==================== PUBLIC STUDENT ACCESS API ====================
// Search student by name and birth date (Public)
app.post('/api/public/find-student', async (req, res) => {
try {
const { nama, tanggalLahir } = req.body;
if (!nama || !tanggalLahir) {
return res.status(400).json({ success: false, message: 'Nama dan Tanggal Lahir harus diisi' });
}
console.log(`Searching for: Name="${nama}", DOB="${tanggalLahir}"`);
// Helper to format date from YYYY-MM-DD to DD/MM/YYYY
const formatDateDMY = (dateStr) => {
if (!dateStr) return '';
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
return dateStr;
};
// Helper to format date from YYYY-MM-DD to DD-MM-YYYY
const formatDateDMYHyphen = (dateStr) => {
if (!dateStr) return '';
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parts[2]}-${parts[1]}-${parts[0]}`;
}
return dateStr;
};
const tanggalLahirDMY = formatDateDMY(tanggalLahir);
const tanggalLahirDMYHyphen = formatDateDMYHyphen(tanggalLahir);
// Try multiple date formats
// Robust search: Remove all spaces for name comparison to handle typo/spacing issues
// AND try multiple date formats
let query = `
SELECT id, nis, nama, tahun_ajaran, tanggal_lahir
FROM students
WHERE
LOWER(REPLACE(nama, ' ', '')) = LOWER(REPLACE(?, ' ', ''))
AND (
tanggal_lahir = ?
OR tanggal_lahir = ?
OR tanggal_lahir = ?
)
`;
let params = [nama, tanggalLahir, tanggalLahirDMY, tanggalLahirDMYHyphen];
const [rows] = await pool.query(query, params);
if (rows.length === 0) {
console.log('Search returned no results');
// Check for partial matches for debugging
// Name match (ignoring spaces)
const [nameCheck] = await pool.query(
"SELECT count(*) as count FROM students WHERE LOWER(REPLACE(nama, ' ', '')) = LOWER(REPLACE(?, ' ', ''))",
[nama]
);
// Date match (any format)
const [dateCheck] = await pool.query(
"SELECT count(*) as count FROM students WHERE tanggal_lahir = ? OR tanggal_lahir = ? OR tanggal_lahir = ?",
[tanggalLahir, tanggalLahirDMY, tanggalLahirDMYHyphen]
);
const debugInfo = {
nameMatches: nameCheck[0].count,
dateMatches: dateCheck[0].count,
searchedName: nama,
searchedDate: tanggalLahir,
searchedDateDMY: tanggalLahirDMY,
searchedDateDMYHyphen: tanggalLahirDMYHyphen
};
console.log('Debug info:', debugInfo);
let message = 'Data tidak ditemukan.';
if (nameCheck[0].count > 0 && dateCheck[0].count === 0) {
message = 'Nama siswa ditemukan, tetapi tanggal lahir tidak cocok. Pastikan tanggal lahir sesuai data sekolah.';
} else if (nameCheck[0].count === 0 && dateCheck[0].count > 0) {
message = 'Tanggal lahir valid, tetapi nama tidak ditemukan. Periksa penulisan nama (sesuai Ijazah/Akta).';
} else {
message = 'Data siswa tidak ditemukan. Periksa Nama dan Tanggal Lahir.';
}
return res.status(404).json({
success: false,
message,
debug: debugInfo
});
}
// Jika ditemukan, kembalikan data minimal (NIS & ID) untuk akses selanjutnya
res.json({
success: true,
student: convertKeysToCamel(rows[0])
});
} catch (error) {
console.error('Public search error:', error);
res.status(500).json({ success: false, message: 'Terjadi kesalahan pada server' });
}
});
// Health Check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
env: process.env.NODE_ENV,
db_name: process.env.DB_NAME
});
});
// Get student detail for public view (requires student ID)
app.get('/api/public/students/:id', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM students WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ message: 'Siswa tidak ditemukan' });
res.json(convertKeysToCamel(rows[0]));
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// ==================== ACADEMIC YEARS API ====================
// Get all years
app.get('/api/years', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM academic_years ORDER BY year_name DESC');
res.json(rows.map(convertKeysToCamel));
} catch (error) {
console.error('Error fetching years:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data tahun ajaran' });
}
});
// Add year
app.post('/api/years', async (req, res) => {
try {
const { yearName } = req.body;
await pool.query('INSERT INTO academic_years (year_name) VALUES (?)', [yearName]);
res.json({ success: true, message: 'Tahun ajaran berhasil ditambah' });
} catch (error) {
console.error('Error adding year:', error);
res.status(500).json({ success: false, message: 'Gagal menambah tahun ajaran' });
}
});
// Set active year
app.post('/api/years/:id/active', async (req, res) => {
try {
const yearId = req.params.id;
// Set all to false
await pool.query('UPDATE academic_years SET is_active = FALSE');
// Set target to true
await pool.query('UPDATE academic_years SET is_active = TRUE WHERE id = ?', [yearId]);
// Also sync to global settings for backward compatibility/quick access
const [yearRows] = await pool.query('SELECT year_name FROM academic_years WHERE id = ?', [yearId]);
if (yearRows.length > 0) {
const newYear = yearRows[0].year_name;
const [settingsRows] = await pool.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']);
if (settingsRows.length > 0) {
const currentSettings = JSON.parse(settingsRows[0].setting_value);
currentSettings.tahunAjaran = newYear;
await pool.query('UPDATE settings SET setting_value = ? WHERE setting_key = ?',
[JSON.stringify(currentSettings), 'app_settings']);
}
}
res.json({ success: true, message: 'Tahun ajaran aktif berhasil diubah' });
} catch (error) {
console.error('Error setting active year:', error);
res.status(500).json({ success: false, message: 'Gagal mengubah tahun ajaran aktif' });
}
});
// Delete year
app.delete('/api/years/:id', async (req, res) => {
try {
const yearId = req.params.id;
const [year] = await pool.query('SELECT is_active FROM academic_years WHERE id = ?', [yearId]);
if (year.length > 0 && year[0].is_active) {
return res.status(400).json({ success: false, message: 'Tidak dapat menghapus tahun ajaran yang sedang aktif' });
}
await pool.query('DELETE FROM academic_years WHERE id = ?', [yearId]);
res.json({ success: true, message: 'Tahun ajaran berhasil dihapus' });
} catch (error) {
console.error('Error deleting year:', error);
res.status(500).json({ success: false, message: 'Gagal menghapus tahun ajaran' });
}
});
// ==================== USER MANAGEMENT API ====================
// Get all users
app.get('/api/users', async (req, res) => {
try {
const [rows] = await pool.query('SELECT id, username, full_name, role, created_at FROM users ORDER BY full_name ASC');
res.json(rows.map(convertKeysToCamel));
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({ success: false, message: 'Gagal mengambil data pengguna' });
}
});
// Add new user
app.post('/api/users', async (req, res) => {
try {
const { username, password, fullName, role } = req.body;
if (!username || !password || !fullName || !role) {
return res.status(400).json({ success: false, message: 'Semua data harus diisi' });
}
const hashedPassword = await bcrypt.hash(password, 10);
await pool.query(
'INSERT INTO users (username, password, full_name, role) VALUES (?, ?, ?, ?)',
[username, hashedPassword, fullName, role]
);
res.json({ success: true, message: 'Pengguna berhasil ditambahkan' });
} catch (error) {
console.error('Error adding user:', error);
if (error.code === 'ER_DUP_ENTRY') {
res.status(400).json({ success: false, message: 'Username sudah digunakan' });
} else {
res.status(500).json({ success: false, message: 'Gagal menambah pengguna' });
}
}
});
// Update user
app.put('/api/users/:id', async (req, res) => {
try {
const userId = req.params.id;
const { username, password, fullName, role } = req.body;
if (!username || !fullName || !role) {
return res.status(400).json({ success: false, message: 'Data nama, username dan role harus diisi' });
}
// Check if username exists for OTHER users
const [existing] = await pool.query('SELECT id FROM users WHERE username = ? AND id != ?', [username, userId]);
if (existing.length > 0) {
return res.status(400).json({ success: false, message: 'Username sudah digunakan oleh pengguna lain' });
}
if (password && password.trim() !== '') {
// Update with password
const hashedPassword = await bcrypt.hash(password, 10);
await pool.query(
'UPDATE users SET username = ?, password = ?, full_name = ?, role = ? WHERE id = ?',
[username, hashedPassword, fullName, role, userId]
);
} else {
// Update without password
await pool.query(
'UPDATE users SET username = ?, full_name = ?, role = ? WHERE id = ?',
[username, fullName, role, userId]
);
}
res.json({ success: true, message: 'Data pengguna berhasil diperbarui' });
} catch (error) {
console.error('Error updating user:', error);
res.status(500).json({ success: false, message: 'Gagal memperbarui pengguna' });
}
});
// Delete user
app.delete('/api/users/:id', async (req, res) => {
try {
const userId = req.params.id;
// Prevent deleting the last admin or the currently logged in user (would need a better check but for now simpler)
const [userRows] = await pool.query('SELECT username FROM users WHERE id = ?', [userId]);
if (userRows.length > 0 && userRows[0].username === 'Kesiswaan') {
return res.status(400).json({ success: false, message: 'Account utama tidak dapat dihapus' });
}
await pool.query('DELETE FROM users WHERE id = ?', [userId]);
res.json({ success: true, message: 'Pengguna berhasil dihapus' });
} catch (error) {
console.error('Error deleting user:', error);
res.status(500).json({ success: false, message: 'Gagal menghapus pengguna' });
}
});
// ==================== STATIC FILES ====================
// Serve static files from the dist directory
app.use(express.static(path.join(__dirname, 'dist')));
// Handle SPA routing
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// ==================== START SERVER ====================
const startServer = async () => {
try {
// Initialize database
await initDatabase();
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n🚀 Server is running on http://localhost:${PORT}`);
console.log(`📦 Database: ${process.env.DB_NAME || 'db_bukuiduk'}`);
console.log(`🌐 Environment: Node.js ${process.version}`);
});
} catch (error) {
console.error('❌ Failed to start server:', error);
process.exit(1);
}
};
startServer();

Binary file not shown.

View File

@@ -0,0 +1,291 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
// Create connection pool
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '',
database: process.env.DB_NAME || 'db_bukuiduk',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
charset: 'utf8mb4'
});
// Initialize database and tables
export const initDatabase = async () => {
let conn;
try {
// First, create connection without database to create it if needed
const initPool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '',
waitForConnections: true,
connectionLimit: 2,
});
conn = await initPool.getConnection();
// Create database if not exists
await conn.query(`CREATE DATABASE IF NOT EXISTS \`${process.env.DB_NAME || 'db_bukuiduk'}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
console.log(`✓ Database '${process.env.DB_NAME || 'db_bukuiduk'}' ready`);
await conn.release();
await initPool.end();
// Now create tables using main pool
const mainConn = await pool.getConnection();
// Users table
await mainConn.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
role ENUM('admin', 'operator') DEFAULT 'operator',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✓ Table users ready');
// Students table with all fields from the application
await mainConn.query(`
CREATE TABLE IF NOT EXISTS students (
id INT AUTO_INCREMENT PRIMARY KEY,
nis VARCHAR(50) NOT NULL,
nama VARCHAR(255) NOT NULL,
tahun_ajaran VARCHAR(20),
foto_diterima_url LONGTEXT,
foto_lulus_url LONGTEXT,
-- A. Keterangan Diri
nama_panggilan VARCHAR(100),
jenis_kelamin ENUM('L', 'P') DEFAULT 'L',
tempat_lahir VARCHAR(100),
tanggal_lahir VARCHAR(50),
agama VARCHAR(50),
kewarganegaraan VARCHAR(100) DEFAULT 'Indonesia',
anak_keberapa VARCHAR(20),
jumlah_saudara_kandung VARCHAR(20),
jumlah_saudara_tiri VARCHAR(20),
status_yatim VARCHAR(50),
bahasa_sehari_hari VARCHAR(100),
-- B. Tempat Tinggal
alamat TEXT,
no_telp VARCHAR(50),
jenis_tempat_tinggal VARCHAR(100),
jarak_ke_sekolah VARCHAR(50),
-- C. Kesehatan
golongan_darah VARCHAR(10),
penyakit VARCHAR(255),
kelainan_jasmani VARCHAR(255),
tinggi_berat VARCHAR(100),
-- D. Pendidikan
pendidikan_asal VARCHAR(255),
no_ijasah_asal VARCHAR(100),
lama_belajar_asal VARCHAR(50),
pindahan_dari VARCHAR(255),
alasan_pindah TEXT,
diterima_di_kelas VARCHAR(50),
program_jurusan VARCHAR(100),
tanggal_diterima VARCHAR(50),
hobby VARCHAR(255),
cita_cita VARCHAR(255),
-- E. Ayah
ayah_nama VARCHAR(255),
ayah_nik VARCHAR(50),
ayah_tahun_lahir VARCHAR(20),
ayah_agama VARCHAR(50),
ayah_warga_negara VARCHAR(100),
ayah_pendidikan VARCHAR(100),
ayah_pekerjaan VARCHAR(100),
ayah_penghasilan VARCHAR(100),
ayah_alamat TEXT,
ayah_no_telp VARCHAR(50),
-- F. Ibu
ibu_nama VARCHAR(255),
ibu_nik VARCHAR(50),
ibu_tahun_lahir VARCHAR(20),
ibu_agama VARCHAR(50),
ibu_warga_negara VARCHAR(100),
ibu_pendidikan VARCHAR(100),
ibu_pekerjaan VARCHAR(100),
ibu_penghasilan VARCHAR(100),
ibu_alamat TEXT,
ibu_no_telp VARCHAR(50),
-- G. Wali
wali_nama VARCHAR(255),
wali_nik VARCHAR(50),
wali_tahun_lahir VARCHAR(20),
wali_agama VARCHAR(50),
wali_pendidikan VARCHAR(100),
wali_pekerjaan VARCHAR(100),
wali_penghasilan VARCHAR(100),
-- H. Beasiswa
bea_siswa1_tahun VARCHAR(20),
bea_siswa1_kelas VARCHAR(50),
bea_siswa1_dari VARCHAR(255),
bea_siswa2_tahun VARCHAR(20),
bea_siswa2_kelas VARCHAR(50),
bea_siswa2_dari VARCHAR(255),
bea_siswa3_tahun VARCHAR(20),
bea_siswa3_kelas VARCHAR(50),
bea_siswa3_dari VARCHAR(255),
-- Meninggalkan Sekolah
tanggal_keluar VARCHAR(50),
alasan_keluar TEXT,
tamat_belajar_tahun VARCHAR(20),
no_ijasah_tamat VARCHAR(100),
-- I. Setelah Pendidikan
pekerjaan_perusahaan VARCHAR(255),
pekerjaan_bidang_usaha VARCHAR(255),
pekerjaan_penghasilan VARCHAR(100),
pekerjaan_sesuai_kompetensi VARCHAR(20),
lanjut_perguruan_tinggi VARCHAR(255),
lanjut_program_studi VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_nis (nis),
INDEX idx_nama (nama),
INDEX idx_tahun_ajaran (tahun_ajaran)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✓ Table students ready');
// Settings table
await mainConn.query(`
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) NOT NULL UNIQUE,
setting_value LONGTEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✓ Table settings ready');
// Student Legers table for PDF semester files
await mainConn.query(`
CREATE TABLE IF NOT EXISTS student_legers (
id INT AUTO_INCREMENT PRIMARY KEY,
student_id INT NOT NULL,
nis VARCHAR(50) NOT NULL,
nama VARCHAR(255) NOT NULL,
semester INT NOT NULL CHECK (semester >= 1 AND semester <= 6),
file_name VARCHAR(255) NOT NULL,
file_data LONGTEXT NOT NULL,
file_size INT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_student_id (student_id),
INDEX idx_nis (nis),
INDEX idx_semester (semester),
UNIQUE KEY unique_student_semester (student_id, semester),
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✓ Table student_legers ready');
// Student Documents table for Ijazah and Sertifikat
await mainConn.query(`
CREATE TABLE IF NOT EXISTS student_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
student_id INT NOT NULL,
nis VARCHAR(50) NOT NULL,
nama VARCHAR(255) NOT NULL,
doc_type ENUM('ijazah', 'sertifikat') NOT NULL,
doc_name VARCHAR(255),
file_name VARCHAR(255) NOT NULL,
file_data LONGTEXT NOT NULL,
file_size INT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_student_id (student_id),
INDEX idx_doc_type (doc_type),
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✓ Table student_documents ready');
// Insert default user if not exists
const [existingUsers] = await mainConn.query('SELECT * FROM users WHERE username = ?', ['Kesiswaan']);
if (existingUsers.length === 0) {
const bcryptModule = await import('bcryptjs');
const bcrypt = bcryptModule.default;
const hashedPassword = await bcrypt.hash('Smanab#1', 10);
await mainConn.query(
'INSERT INTO users (username, password, full_name, role) VALUES (?, ?, ?, ?)',
['Kesiswaan', hashedPassword, 'Administrator Kesiswaan', 'admin']
);
console.log('✓ Default user created: Kesiswaan');
}
// Insert default settings if not exists
const [existingSettings] = await mainConn.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']);
if (existingSettings.length === 0) {
const defaultSettings = {
schoolName: 'SMA NEGERI 1 ABIANSEMAL',
logoUrl: 'https://iili.io/KN7pUR2.png',
faviconUrl: '',
tahunAjaran: '2025/2026',
margins: { top: 20, right: 20, bottom: 20, left: 20 }
};
await mainConn.query(
'INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)',
['app_settings', JSON.stringify(defaultSettings)]
);
console.log('✓ Default settings created');
}
// Academic Years table
await mainConn.query(`
CREATE TABLE IF NOT EXISTS academic_years (
id INT AUTO_INCREMENT PRIMARY KEY,
year_name VARCHAR(20) NOT NULL UNIQUE,
is_active BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✓ Table academic_years ready');
// Insert default academic year if none exist
const [existingYears] = await mainConn.query('SELECT * FROM academic_years');
if (existingYears.length === 0) {
await mainConn.query(
'INSERT INTO academic_years (year_name, is_active) VALUES (?, ?)',
['2025/2026', true]
);
console.log('✓ Default academic year created');
}
mainConn.release();
console.log('✓ Database initialization complete');
} catch (error) {
console.error('Database initialization error:', error);
throw error;
}
};
export default pool;