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:
313
app.js
313
app.js
@@ -1,5 +1,5 @@
|
||||
// ============================================
|
||||
// APLIKASI DOKUMENTASI NASABAH KOPERASI
|
||||
// APLIKASI DOKUMENTASI NASABAH LPD GERANA
|
||||
// ============================================
|
||||
|
||||
// Global Variables
|
||||
@@ -15,7 +15,7 @@ const elements = {
|
||||
canvas: document.getElementById('canvas'),
|
||||
preview: document.getElementById('preview'),
|
||||
previewImage: document.getElementById('previewImage'),
|
||||
|
||||
|
||||
// Buttons
|
||||
startBtn: document.getElementById('startBtn'),
|
||||
captureBtn: document.getElementById('captureBtn'),
|
||||
@@ -24,7 +24,7 @@ const elements = {
|
||||
printBtn: document.getElementById('printBtn'),
|
||||
deleteBtn: document.getElementById('deleteBtn'),
|
||||
exportBtn: document.getElementById('exportBtn'),
|
||||
|
||||
|
||||
// Form
|
||||
form: document.getElementById('nasabahForm'),
|
||||
nama: document.getElementById('nama'),
|
||||
@@ -32,132 +32,119 @@ const elements = {
|
||||
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')
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// INDEXEDDB SETUP
|
||||
// API CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
function initDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('KoperasiDB', 1);
|
||||
|
||||
request.onerror = () => {
|
||||
showToast('Gagal membuka database', 'error');
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result;
|
||||
console.log('Database berhasil dibuka');
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
});
|
||||
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);
|
||||
};
|
||||
});
|
||||
async function saveToDatabase(data) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dokumentasi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to save');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Data berhasil disimpan dengan ID:', result.id);
|
||||
return result.id;
|
||||
} catch (error) {
|
||||
console.error('Error saving to database:', error);
|
||||
throw 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);
|
||||
};
|
||||
});
|
||||
async function getAllData() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dokumentasi`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
throw 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);
|
||||
};
|
||||
});
|
||||
async function getDataById(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dokumentasi/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -167,7 +154,7 @@ function deleteData(id) {
|
||||
async function startWebcam() {
|
||||
try {
|
||||
showStatus('Meminta akses webcam...', 'info');
|
||||
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
@@ -176,15 +163,15 @@ async function startWebcam() {
|
||||
},
|
||||
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');
|
||||
@@ -195,41 +182,41 @@ async function startWebcam() {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -238,16 +225,16 @@ function stopWebcam() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -262,13 +249,13 @@ async function saveDocumentation() {
|
||||
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(),
|
||||
@@ -279,21 +266,21 @@ async function saveDocumentation() {
|
||||
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');
|
||||
@@ -312,32 +299,32 @@ function setDefaultDate() {
|
||||
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 =>
|
||||
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})">
|
||||
@@ -350,7 +337,7 @@ async function loadGallery(searchTerm = '') {
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading gallery:', error);
|
||||
showToast('❌ Gagal memuat galeri', 'error');
|
||||
@@ -362,12 +349,12 @@ function updateGalleryStats(shown, total) {
|
||||
elements.galleryStats.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let statsText = `📊 Total: ${total} dokumentasi`;
|
||||
if (shown < total) {
|
||||
statsText += ` • Ditampilkan: ${shown}`;
|
||||
}
|
||||
|
||||
|
||||
elements.galleryStats.innerHTML = statsText;
|
||||
}
|
||||
|
||||
@@ -382,9 +369,9 @@ async function showDetail(id) {
|
||||
showToast('❌ Data tidak ditemukan', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
currentDetailId = id;
|
||||
|
||||
|
||||
elements.modalBody.innerHTML = `
|
||||
<div class="detail-container">
|
||||
<div>
|
||||
@@ -427,9 +414,9 @@ async function showDetail(id) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
elements.modal.classList.add('show');
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing detail:', error);
|
||||
showToast('❌ Gagal memuat detail', 'error');
|
||||
@@ -447,11 +434,11 @@ function printDetail() {
|
||||
|
||||
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');
|
||||
@@ -470,30 +457,30 @@ async function deleteDetail() {
|
||||
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');
|
||||
@@ -511,12 +498,12 @@ function convertToCSV(data) {
|
||||
item.catatan || '',
|
||||
formatDateTime(item.timestamp)
|
||||
]);
|
||||
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
@@ -530,7 +517,7 @@ function showStatus(message, type) {
|
||||
elements.webcamStatus.className = 'status-message';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
elements.webcamStatus.innerHTML = message;
|
||||
elements.webcamStatus.className = `status-message ${type}`;
|
||||
}
|
||||
@@ -538,7 +525,7 @@ function showStatus(message, type) {
|
||||
function showToast(message, type = 'success') {
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.className = `toast ${type} show`;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
elements.toast.classList.remove('show');
|
||||
}, 3000);
|
||||
@@ -584,7 +571,7 @@ function setupEventListeners() {
|
||||
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) => {
|
||||
@@ -592,18 +579,18 @@ function setupEventListeners() {
|
||||
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') {
|
||||
@@ -618,20 +605,20 @@ function setupEventListeners() {
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
// Initialize database
|
||||
await initDatabase();
|
||||
|
||||
// Check API connection
|
||||
const isConnected = await checkApiConnection();
|
||||
|
||||
// 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');
|
||||
|
||||
Reference in New Issue
Block a user