feat: Integrate MySQL backend and rebrand to LPD Gerana

- Add Node.js Express backend with REST API
- Create database schema and server.js
- Migrate frontend from IndexedDB to MySQL API
- Add environment configuration (.env)
- Rebrand from Koperasi to LPD Gerana
- Update all documentation and UI text
- Add configurable server port setting

Features:
- POST /api/dokumentasi - Create documentation
- GET /api/dokumentasi - Retrieve all (with search)
- GET /api/dokumentasi/:id - Get single record
- DELETE /api/dokumentasi/:id - Delete record
- Full CRUD operations with MySQL persistence
This commit is contained in:
2026-01-19 13:41:01 +08:00
parent 162f8a38a4
commit b9b255ec79
8 changed files with 2472 additions and 171 deletions

View File

@@ -1,4 +1,4 @@
# 📸 Aplikasi Dokumentasi Nasabah Koperasi # 📸 Aplikasi Dokumentasi Nasabah LPD Gerana
Aplikasi web untuk mengambil foto dokumentasi nasabah saat penandatanganan surat perjanjian menggunakan webcam USB. Aplikasi web untuk mengambil foto dokumentasi nasabah saat penandatanganan surat perjanjian menggunakan webcam USB.
@@ -12,7 +12,7 @@ Aplikasi web untuk mengambil foto dokumentasi nasabah saat penandatanganan surat
### 2. Input Data Nasabah ### 2. Input Data Nasabah
- ✅ Nama lengkap nasabah - ✅ Nama lengkap nasabah
- ✅ Nomor anggota koperasi - ✅ Nomor anggota LPD Gerana
- ✅ Jenis perjanjian/dokumen - ✅ Jenis perjanjian/dokumen
- ✅ Tanggal penandatanganan (auto-fill hari ini) - ✅ Tanggal penandatanganan (auto-fill hari ini)
- ✅ Catatan tambahan (opsional) - ✅ Catatan tambahan (opsional)
@@ -115,11 +115,11 @@ Aplikasi web untuk mengambil foto dokumentasi nasabah saat penandatanganan surat
## 🎨 Kustomisasi ## 🎨 Kustomisasi
### Mengubah Logo Koperasi ### Mengubah Logo LPD Gerana
Edit file `style.css` dan tambahkan logo di header: Edit file `style.css` dan tambahkan logo di header:
```css ```css
.app-header::before { .app-header::before {
content: url('logo-koperasi.png'); content: url('logo-lpd-gerana.png');
} }
``` ```
@@ -183,11 +183,11 @@ Edit file `style.css` di bagian `:root`:
## 📄 Lisensi ## 📄 Lisensi
Aplikasi ini bebas digunakan untuk keperluan internal Koperasi. Aplikasi ini bebas digunakan untuk keperluan internal LPD Gerana.
## 🤝 Dukungan ## 🤝 Dukungan
Untuk pertanyaan atau masalah, silakan hubungi IT Support Koperasi. Untuk pertanyaan atau masalah, silakan hubungi IT Support LPD Gerana.
--- ---

167
app.js
View File

@@ -1,5 +1,5 @@
// ============================================ // ============================================
// APLIKASI DOKUMENTASI NASABAH KOPERASI // APLIKASI DOKUMENTASI NASABAH LPD GERANA
// ============================================ // ============================================
// Global Variables // Global Variables
@@ -50,114 +50,101 @@ const elements = {
}; };
// ============================================ // ============================================
// INDEXEDDB SETUP // API CONFIGURATION
// ============================================ // ============================================
function initDatabase() { const API_BASE_URL = 'http://localhost:3000/api';
return new Promise((resolve, reject) => {
const request = indexedDB.open('KoperasiDB', 1);
request.onerror = () => { async function checkApiConnection() {
showToast('Gagal membuka database', 'error'); try {
reject(request.error); const response = await fetch(`${API_BASE_URL}/health`);
}; if (!response.ok) {
throw new Error('API not responding');
request.onsuccess = () => { }
db = request.result; console.log('✅ API connection successful');
console.log('Database berhasil dibuka'); return true;
resolve(db); } catch (error) {
}; console.error('❌ API connection failed:', error);
showToast('⚠️ Tidak dapat terhubung ke server. Pastikan server berjalan.', 'error');
request.onupgradeneeded = (event) => { return false;
db = event.target.result;
if (!db.objectStoreNames.contains('dokumentasi')) {
const objectStore = db.createObjectStore('dokumentasi', {
keyPath: 'id',
autoIncrement: true
});
// Create indexes for searching
objectStore.createIndex('nama', 'nama', { unique: false });
objectStore.createIndex('noAnggota', 'noAnggota', { unique: false });
objectStore.createIndex('tanggal', 'tanggal', { unique: false });
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
console.log('Object store "dokumentasi" berhasil dibuat');
} }
};
});
} }
// ============================================ // ============================================
// DATABASE OPERATIONS // DATABASE OPERATIONS
// ============================================ // ============================================
function saveToDatabase(data) { async function saveToDatabase(data) {
return new Promise((resolve, reject) => { try {
const transaction = db.transaction(['dokumentasi'], 'readwrite'); const response = await fetch(`${API_BASE_URL}/dokumentasi`, {
const objectStore = transaction.objectStore('dokumentasi'); method: 'POST',
headers: {
const request = objectStore.add(data); 'Content-Type': 'application/json'
},
request.onsuccess = () => { body: JSON.stringify(data)
console.log('Data berhasil disimpan dengan ID:', request.result);
resolve(request.result);
};
request.onerror = () => {
console.error('Gagal menyimpan data:', request.error);
reject(request.error);
};
}); });
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save');
} }
function getAllData() { const result = await response.json();
return new Promise((resolve, reject) => { console.log('Data berhasil disimpan dengan ID:', result.id);
const transaction = db.transaction(['dokumentasi'], 'readonly'); return result.id;
const objectStore = transaction.objectStore('dokumentasi'); } catch (error) {
const request = objectStore.getAll(); console.error('Error saving to database:', error);
throw error;
request.onsuccess = () => { }
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
} }
function getDataById(id) { async function getAllData() {
return new Promise((resolve, reject) => { try {
const transaction = db.transaction(['dokumentasi'], 'readonly'); const response = await fetch(`${API_BASE_URL}/dokumentasi`);
const objectStore = transaction.objectStore('dokumentasi');
const request = objectStore.get(id);
request.onsuccess = () => { if (!response.ok) {
resolve(request.result); throw new Error('Failed to fetch data');
};
request.onerror = () => {
reject(request.error);
};
});
} }
function deleteData(id) { const result = await response.json();
return new Promise((resolve, reject) => { return result.data || [];
const transaction = db.transaction(['dokumentasi'], 'readwrite'); } catch (error) {
const objectStore = transaction.objectStore('dokumentasi'); console.error('Error fetching data:', error);
const request = objectStore.delete(id); throw error;
}
}
request.onsuccess = () => { async function getDataById(id) {
resolve(); try {
}; const response = await fetch(`${API_BASE_URL}/dokumentasi/${id}`);
request.onerror = () => { if (!response.ok) {
reject(request.error); throw new Error('Data not found');
}; }
const result = await response.json();
return result.data;
} catch (error) {
console.error('Error fetching data by ID:', error);
throw error;
}
}
async function deleteData(id) {
try {
const response = await fetch(`${API_BASE_URL}/dokumentasi/${id}`, {
method: 'DELETE'
}); });
if (!response.ok) {
throw new Error('Failed to delete');
}
return true;
} catch (error) {
console.error('Error deleting data:', error);
throw error;
}
} }
// ============================================ // ============================================
@@ -618,8 +605,8 @@ function setupEventListeners() {
async function init() { async function init() {
try { try {
// Initialize database // Check API connection
await initDatabase(); const isConnected = await checkApiConnection();
// Set default date // Set default date
setDefaultDate(); setDefaultDate();

630
app.js.backup Normal file
View File

@@ -0,0 +1,630 @@
// ============================================
// APLIKASI DOKUMENTASI NASABAH KOPERASI
// ============================================
// Global Variables
let stream = null;
let capturedImage = null;
let db = null;
let currentDetailId = null;
// DOM Elements
const elements = {
// Webcam
webcam: document.getElementById('webcam'),
canvas: document.getElementById('canvas'),
preview: document.getElementById('preview'),
previewImage: document.getElementById('previewImage'),
// Buttons
startBtn: document.getElementById('startBtn'),
captureBtn: document.getElementById('captureBtn'),
retakeBtn: document.getElementById('retakeBtn'),
saveBtn: document.getElementById('saveBtn'),
printBtn: document.getElementById('printBtn'),
deleteBtn: document.getElementById('deleteBtn'),
exportBtn: document.getElementById('exportBtn'),
// Form
form: document.getElementById('nasabahForm'),
nama: document.getElementById('nama'),
noAnggota: document.getElementById('noAnggota'),
jenisPerjanjian: document.getElementById('jenisPerjanjian'),
tanggal: document.getElementById('tanggal'),
catatan: document.getElementById('catatan'),
// Gallery
gallery: document.getElementById('gallery'),
emptyState: document.getElementById('emptyState'),
searchInput: document.getElementById('searchInput'),
galleryStats: document.getElementById('galleryStats'),
// Modal
modal: document.getElementById('detailModal'),
modalBody: document.getElementById('modalBody'),
closeModal: document.querySelector('.close-modal'),
// Status
webcamStatus: document.getElementById('webcamStatus'),
toast: document.getElementById('toast')
};
// ============================================
// API CONFIGURATION
// ============================================
const API_BASE_URL = 'http://localhost:3000/api';
async function checkApiConnection() {
try {
const response = await fetch(`${API_BASE_URL}/health`);
if (!response.ok) {
throw new Error('API not responding');
}
console.log('✅ API connection successful');
return true;
} catch (error) {
console.error('❌ API connection failed:', error);
showToast('⚠️ Tidak dapat terhubung ke server. Pastikan server berjalan.', 'error');
return false;
}
}
// ============================================
// DATABASE OPERATIONS
// ============================================
function saveToDatabase(data) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['dokumentasi'], 'readwrite');
const objectStore = transaction.objectStore('dokumentasi');
const request = objectStore.add(data);
request.onsuccess = () => {
console.log('Data berhasil disimpan dengan ID:', request.result);
resolve(request.result);
};
request.onerror = () => {
console.error('Gagal menyimpan data:', request.error);
reject(request.error);
};
});
}
function getAllData() {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['dokumentasi'], 'readonly');
const objectStore = transaction.objectStore('dokumentasi');
const request = objectStore.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
function getDataById(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['dokumentasi'], 'readonly');
const objectStore = transaction.objectStore('dokumentasi');
const request = objectStore.get(id);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
function deleteData(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['dokumentasi'], 'readwrite');
const objectStore = transaction.objectStore('dokumentasi');
const request = objectStore.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
// ============================================
// WEBCAM FUNCTIONS
// ============================================
async function startWebcam() {
try {
showStatus('Meminta akses webcam...', 'info');
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
},
audio: false
});
elements.webcam.srcObject = stream;
elements.startBtn.style.display = 'none';
elements.captureBtn.style.display = 'inline-flex';
showStatus('Webcam aktif. Posisikan nasabah dan klik "Ambil Foto"', 'success');
showToast('✅ Webcam berhasil diaktifkan', 'success');
} catch (error) {
console.error('Error accessing webcam:', error);
showStatus('Gagal mengakses webcam. Pastikan webcam terhubung dan izin diberikan.', 'error');
showToast('❌ Gagal mengakses webcam', 'error');
}
}
function capturePhoto() {
const canvas = elements.canvas;
const context = canvas.getContext('2d');
// Set canvas size to match video
canvas.width = elements.webcam.videoWidth;
canvas.height = elements.webcam.videoHeight;
// Draw video frame to canvas
context.drawImage(elements.webcam, 0, 0, canvas.width, canvas.height);
// Get image data
capturedImage = canvas.toDataURL('image/jpeg', 0.9);
// Show preview
elements.previewImage.src = capturedImage;
elements.webcam.style.display = 'none';
elements.preview.style.display = 'block';
// Update buttons
elements.captureBtn.style.display = 'none';
elements.retakeBtn.style.display = 'inline-flex';
elements.saveBtn.style.display = 'inline-flex';
showStatus('Foto berhasil diambil. Periksa hasilnya dan klik "Simpan" jika sudah sesuai.', 'success');
showToast('📸 Foto berhasil diambil', 'success');
}
function retakePhoto() {
capturedImage = null;
elements.webcam.style.display = 'block';
elements.preview.style.display = 'none';
elements.retakeBtn.style.display = 'none';
elements.saveBtn.style.display = 'none';
elements.captureBtn.style.display = 'inline-flex';
showStatus('Webcam aktif. Ambil foto ulang.', 'info');
}
function stopWebcam() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
elements.webcam.srcObject = null;
elements.webcam.style.display = 'block';
elements.preview.style.display = 'none';
elements.startBtn.style.display = 'inline-flex';
elements.captureBtn.style.display = 'none';
elements.retakeBtn.style.display = 'none';
elements.saveBtn.style.display = 'none';
capturedImage = null;
}
// ============================================
// FORM FUNCTIONS
// ============================================
async function saveDocumentation() {
// Validate form
if (!elements.form.checkValidity()) {
elements.form.reportValidity();
showToast('⚠️ Lengkapi semua data yang wajib diisi', 'error');
return;
}
// Validate photo
if (!capturedImage) {
showToast('⚠️ Ambil foto terlebih dahulu', 'error');
return;
}
// Prepare data
const data = {
nama: elements.nama.value.trim(),
noAnggota: elements.noAnggota.value.trim(),
jenisPerjanjian: elements.jenisPerjanjian.value,
tanggal: elements.tanggal.value,
catatan: elements.catatan.value.trim(),
foto: capturedImage,
timestamp: new Date().toISOString()
};
try {
await saveToDatabase(data);
showToast('✅ Dokumentasi berhasil disimpan', 'success');
// Reset form and webcam
elements.form.reset();
setDefaultDate();
stopWebcam();
showStatus('', '');
// Reload gallery
await loadGallery();
} catch (error) {
console.error('Error saving documentation:', error);
showToast('❌ Gagal menyimpan dokumentasi', 'error');
}
}
function setDefaultDate() {
const today = new Date().toISOString().split('T')[0];
elements.tanggal.value = today;
}
// ============================================
// GALLERY FUNCTIONS
// ============================================
async function loadGallery(searchTerm = '') {
try {
const allData = await getAllData();
// Filter by search term
let filteredData = allData;
if (searchTerm) {
const term = searchTerm.toLowerCase();
filteredData = allData.filter(item =>
item.nama.toLowerCase().includes(term) ||
item.noAnggota.toLowerCase().includes(term)
);
}
// Sort by timestamp (newest first)
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Update stats
updateGalleryStats(filteredData.length, allData.length);
// Show empty state if no data
if (filteredData.length === 0) {
elements.gallery.innerHTML = '';
elements.emptyState.style.display = 'block';
return;
}
elements.emptyState.style.display = 'none';
// Render gallery items
elements.gallery.innerHTML = filteredData.map(item => `
<div class="gallery-item" data-id="${item.id}" onclick="showDetail(${item.id})">
<img src="${item.foto}" alt="${item.nama}" class="gallery-item-image">
<div class="gallery-item-info">
<h3>${escapeHtml(item.nama)}</h3>
<p><strong>No. Anggota:</strong> ${escapeHtml(item.noAnggota)}</p>
<p><strong>Perjanjian:</strong> ${escapeHtml(item.jenisPerjanjian)}</p>
<p class="gallery-item-date">📅 ${formatDate(item.tanggal)} • 🕐 ${formatTime(item.timestamp)}</p>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading gallery:', error);
showToast('❌ Gagal memuat galeri', 'error');
}
}
function updateGalleryStats(shown, total) {
if (total === 0) {
elements.galleryStats.innerHTML = '';
return;
}
let statsText = `📊 Total: ${total} dokumentasi`;
if (shown < total) {
statsText += ` • Ditampilkan: ${shown}`;
}
elements.galleryStats.innerHTML = statsText;
}
// ============================================
// DETAIL & PRINT FUNCTIONS
// ============================================
async function showDetail(id) {
try {
const data = await getDataById(id);
if (!data) {
showToast('❌ Data tidak ditemukan', 'error');
return;
}
currentDetailId = id;
elements.modalBody.innerHTML = `
<div class="detail-container">
<div>
<img src="${data.foto}" alt="${escapeHtml(data.nama)}" class="detail-image">
</div>
<div class="detail-info">
<h2>📋 Detail Dokumentasi</h2>
<div class="detail-row">
<div class="detail-label">Nama Lengkap</div>
<div class="detail-value">${escapeHtml(data.nama)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Nomor Anggota</div>
<div class="detail-value">${escapeHtml(data.noAnggota)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Jenis Perjanjian</div>
<div class="detail-value">${escapeHtml(data.jenisPerjanjian)}</div>
</div>
<div class="detail-row">
<div class="detail-label">Tanggal Penandatanganan</div>
<div class="detail-value">${formatDate(data.tanggal)}</div>
</div>
${data.catatan ? `
<div class="detail-row">
<div class="detail-label">Catatan</div>
<div class="detail-value">${escapeHtml(data.catatan)}</div>
</div>
` : ''}
<div class="detail-row">
<div class="detail-label">Waktu Pengambilan Foto</div>
<div class="detail-value">${formatDateTime(data.timestamp)}</div>
</div>
</div>
</div>
`;
elements.modal.classList.add('show');
} catch (error) {
console.error('Error showing detail:', error);
showToast('❌ Gagal memuat detail', 'error');
}
}
function closeModal() {
elements.modal.classList.remove('show');
currentDetailId = null;
}
function printDetail() {
window.print();
}
async function deleteDetail() {
if (!currentDetailId) return;
if (!confirm('Apakah Anda yakin ingin menghapus dokumentasi ini?')) {
return;
}
try {
await deleteData(currentDetailId);
showToast('✅ Dokumentasi berhasil dihapus', 'success');
closeModal();
await loadGallery();
} catch (error) {
console.error('Error deleting data:', error);
showToast('❌ Gagal menghapus dokumentasi', 'error');
}
}
// ============================================
// EXPORT FUNCTION
// ============================================
async function exportData() {
try {
const allData = await getAllData();
if (allData.length === 0) {
showToast('⚠️ Tidak ada data untuk diekspor', 'error');
return;
}
// Create CSV data (without photos for file size)
const csvData = convertToCSV(allData);
// Download CSV
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `dokumentasi_nasabah_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showToast('✅ Data berhasil diekspor', 'success');
} catch (error) {
console.error('Error exporting data:', error);
showToast('❌ Gagal mengekspor data', 'error');
}
}
function convertToCSV(data) {
const headers = ['ID', 'Nama', 'No. Anggota', 'Jenis Perjanjian', 'Tanggal', 'Catatan', 'Waktu Input'];
const rows = data.map(item => [
item.id,
item.nama,
item.noAnggota,
item.jenisPerjanjian,
formatDate(item.tanggal),
item.catatan || '',
formatDateTime(item.timestamp)
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
return csvContent;
}
// ============================================
// UTILITY FUNCTIONS
// ============================================
function showStatus(message, type) {
if (!message) {
elements.webcamStatus.innerHTML = '';
elements.webcamStatus.className = 'status-message';
return;
}
elements.webcamStatus.innerHTML = message;
elements.webcamStatus.className = `status-message ${type}`;
}
function showToast(message, type = 'success') {
elements.toast.textContent = message;
elements.toast.className = `toast ${type} show`;
setTimeout(() => {
elements.toast.classList.remove('show');
}, 3000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return new Date(dateString).toLocaleDateString('id-ID', options);
}
function formatTime(isoString) {
return new Date(isoString).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
});
}
function formatDateTime(isoString) {
const date = new Date(isoString);
return `${date.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})} pukul ${date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
})}`;
}
// ============================================
// EVENT LISTENERS
// ============================================
function setupEventListeners() {
// Webcam controls
elements.startBtn.addEventListener('click', startWebcam);
elements.captureBtn.addEventListener('click', capturePhoto);
elements.retakeBtn.addEventListener('click', retakePhoto);
elements.saveBtn.addEventListener('click', saveDocumentation);
// Modal
elements.closeModal.addEventListener('click', closeModal);
elements.modal.addEventListener('click', (e) => {
if (e.target === elements.modal) {
closeModal();
}
});
elements.printBtn.addEventListener('click', printDetail);
elements.deleteBtn.addEventListener('click', deleteDetail);
// Search
elements.searchInput.addEventListener('input', (e) => {
loadGallery(e.target.value);
});
// Export
elements.exportBtn.addEventListener('click', exportData);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
}
// ============================================
// INITIALIZATION
// ============================================
async function init() {
try {
// Initialize database
await initDatabase();
// Set default date
setDefaultDate();
// Setup event listeners
setupEventListeners();
// Load gallery
await loadGallery();
console.log('✅ Aplikasi berhasil diinisialisasi');
} catch (error) {
console.error('Error initializing app:', error);
showToast('❌ Gagal menginisialisasi aplikasi', 'error');
}
}
// Start the app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Make showDetail function globally accessible
window.showDetail = showDetail;

34
database.sql Normal file
View File

@@ -0,0 +1,34 @@
-- Database Schema untuk Aplikasi Dokumentasi Nasabah LPD Gerana
-- Buat database jika belum ada
CREATE DATABASE IF NOT EXISTS dokumentasi CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dokumentasi;
-- Tabel dokumentasi
CREATE TABLE IF NOT EXISTS dokumentasi (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
no_anggota VARCHAR(100) NOT NULL,
jenis_perjanjian VARCHAR(255) NOT NULL,
tanggal DATE NOT NULL,
catatan TEXT,
foto LONGTEXT NOT NULL COMMENT 'Base64 encoded image',
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_nama (nama),
INDEX idx_no_anggota (no_anggota),
INDEX idx_tanggal (tanggal),
INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Tabel untuk tracking aktivitas (optional)
CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(50) NOT NULL,
dokumentasi_id INT,
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_dokumentasi_id (dokumentasi_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (dokumentasi_id) REFERENCES dokumentasi(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dokumentasi Nasabah Koperasi</title> <title>Dokumentasi Nasabah LPD Gerana</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -14,7 +14,7 @@
<!-- Header --> <!-- Header -->
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="header-content">
<h1>📸 Dokumentasi Nasabah Koperasi</h1> <h1>📸 Dokumentasi Nasabah LPD Gerana</h1>
<p>Sistem Pengambilan Foto Penandatanganan Perjanjian</p> <p>Sistem Pengambilan Foto Penandatanganan Perjanjian</p>
</div> </div>
</header> </header>

1357
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "dokumentasi-nasabah-lpd-gerana",
"version": "1.0.0",
"description": "Aplikasi dokumentasi nasabah saat penandatanganan perjanjian LPD Gerana",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"setup-db": "mysql -u doc -p'doc2026!' < database.sql"
},
"keywords": [
"lpd-gerana",
"dokumentasi",
"webcam",
"mysql"
],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.5",
"dotenv": "^16.3.1",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

265
server.js Normal file
View File

@@ -0,0 +1,265 @@
require('dotenv').config();
const express = require('express');
const mysql = require('mysql2/promise');
const cors = require('cors');
const app = express();
const PORT = process.env.SERVER_PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' })); // Increased limit for base64 images
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(express.static('.')); // Serve static files from current directory
// Database connection pool
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Test database connection
async function testConnection() {
try {
const connection = await pool.getConnection();
console.log('✅ Database connected successfully');
connection.release();
} catch (error) {
console.error('❌ Database connection failed:', error.message);
process.exit(1);
}
}
// ============================================
// API ENDPOINTS
// ============================================
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: 'Server is running' });
});
// Create new documentation
app.post('/api/dokumentasi', async (req, res) => {
try {
const { nama, noAnggota, jenisPerjanjian, tanggal, catatan, foto } = req.body;
// Validation
if (!nama || !noAnggota || !jenisPerjanjian || !tanggal || !foto) {
return res.status(400).json({
error: 'Missing required fields',
required: ['nama', 'noAnggota', 'jenisPerjanjian', 'tanggal', 'foto']
});
}
const [result] = await pool.execute(
`INSERT INTO dokumentasi (nama, no_anggota, jenis_perjanjian, tanggal, catatan, foto)
VALUES (?, ?, ?, ?, ?, ?)`,
[nama, noAnggota, jenisPerjanjian, tanggal, catatan || null, foto]
);
// Log activity
await pool.execute(
`INSERT INTO activity_log (action, dokumentasi_id, details) VALUES (?, ?, ?)`,
['CREATE', result.insertId, `Created documentation for ${nama}`]
);
res.status(201).json({
success: true,
message: 'Dokumentasi berhasil disimpan',
id: result.insertId
});
} catch (error) {
console.error('Error creating documentation:', error);
res.status(500).json({
error: 'Failed to create documentation',
details: error.message
});
}
});
// Get all documentation (with optional search)
app.get('/api/dokumentasi', async (req, res) => {
try {
const { search } = req.query;
let query = 'SELECT id, nama, no_anggota as noAnggota, jenis_perjanjian as jenisPerjanjian, tanggal, catatan, foto, timestamp FROM dokumentasi';
let params = [];
if (search) {
query += ' WHERE nama LIKE ? OR no_anggota LIKE ?';
params = [`%${search}%`, `%${search}%`];
}
query += ' ORDER BY timestamp DESC';
const [rows] = await pool.execute(query, params);
res.json({
success: true,
count: rows.length,
data: rows
});
} catch (error) {
console.error('Error fetching documentation:', error);
res.status(500).json({
error: 'Failed to fetch documentation',
details: error.message
});
}
});
// Get single documentation by ID
app.get('/api/dokumentasi/:id', async (req, res) => {
try {
const { id } = req.params;
const [rows] = await pool.execute(
`SELECT id, nama, no_anggota as noAnggota, jenis_perjanjian as jenisPerjanjian,
tanggal, catatan, foto, timestamp
FROM dokumentasi WHERE id = ?`,
[id]
);
if (rows.length === 0) {
return res.status(404).json({
error: 'Documentation not found'
});
}
res.json({
success: true,
data: rows[0]
});
} catch (error) {
console.error('Error fetching documentation:', error);
res.status(500).json({
error: 'Failed to fetch documentation',
details: error.message
});
}
});
// Delete documentation
app.delete('/api/dokumentasi/:id', async (req, res) => {
try {
const { id } = req.params;
// Check if exists
const [existing] = await pool.execute(
'SELECT nama FROM dokumentasi WHERE id = ?',
[id]
);
if (existing.length === 0) {
return res.status(404).json({
error: 'Documentation not found'
});
}
// Delete (activity_log will cascade delete automatically)
await pool.execute('DELETE FROM dokumentasi WHERE id = ?', [id]);
res.json({
success: true,
message: 'Dokumentasi berhasil dihapus'
});
} catch (error) {
console.error('Error deleting documentation:', error);
res.status(500).json({
error: 'Failed to delete documentation',
details: error.message
});
}
});
// Get statistics
app.get('/api/stats', async (req, res) => {
try {
const [countResult] = await pool.execute(
'SELECT COUNT(*) as total FROM dokumentasi'
);
const [recentResult] = await pool.execute(
'SELECT COUNT(*) as recent FROM dokumentasi WHERE DATE(timestamp) = CURDATE()'
);
res.json({
success: true,
data: {
total: countResult[0].total,
today: recentResult[0].recent
}
});
} catch (error) {
console.error('Error fetching stats:', error);
res.status(500).json({
error: 'Failed to fetch statistics',
details: error.message
});
}
});
// ============================================
// ERROR HANDLING
// ============================================
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Endpoint not found' });
});
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
details: err.message
});
});
// ============================================
// START SERVER
// ============================================
async function startServer() {
try {
await testConnection();
app.listen(PORT, () => {
console.log(`\n🚀 Server running on http://localhost:${PORT}`);
console.log(`📊 API endpoints:`);
console.log(` GET /api/health`);
console.log(` POST /api/dokumentasi`);
console.log(` GET /api/dokumentasi`);
console.log(` GET /api/dokumentasi/:id`);
console.log(` DELETE /api/dokumentasi/:id`);
console.log(` GET /api/stats`);
console.log(`\n✨ Application ready at http://localhost:${PORT}/index.html\n`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
startServer();
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM signal received: closing HTTP server');
await pool.end();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('\nSIGINT signal received: closing HTTP server');
await pool.end();
process.exit(0);
});