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:
12
README.md
12
README.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
313
app.js
313
app.js
@@ -1,5 +1,5 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// APLIKASI DOKUMENTASI NASABAH KOPERASI
|
// APLIKASI DOKUMENTASI NASABAH LPD GERANA
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Global Variables
|
// Global Variables
|
||||||
@@ -15,7 +15,7 @@ const elements = {
|
|||||||
canvas: document.getElementById('canvas'),
|
canvas: document.getElementById('canvas'),
|
||||||
preview: document.getElementById('preview'),
|
preview: document.getElementById('preview'),
|
||||||
previewImage: document.getElementById('previewImage'),
|
previewImage: document.getElementById('previewImage'),
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
startBtn: document.getElementById('startBtn'),
|
startBtn: document.getElementById('startBtn'),
|
||||||
captureBtn: document.getElementById('captureBtn'),
|
captureBtn: document.getElementById('captureBtn'),
|
||||||
@@ -24,7 +24,7 @@ const elements = {
|
|||||||
printBtn: document.getElementById('printBtn'),
|
printBtn: document.getElementById('printBtn'),
|
||||||
deleteBtn: document.getElementById('deleteBtn'),
|
deleteBtn: document.getElementById('deleteBtn'),
|
||||||
exportBtn: document.getElementById('exportBtn'),
|
exportBtn: document.getElementById('exportBtn'),
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
form: document.getElementById('nasabahForm'),
|
form: document.getElementById('nasabahForm'),
|
||||||
nama: document.getElementById('nama'),
|
nama: document.getElementById('nama'),
|
||||||
@@ -32,132 +32,119 @@ const elements = {
|
|||||||
jenisPerjanjian: document.getElementById('jenisPerjanjian'),
|
jenisPerjanjian: document.getElementById('jenisPerjanjian'),
|
||||||
tanggal: document.getElementById('tanggal'),
|
tanggal: document.getElementById('tanggal'),
|
||||||
catatan: document.getElementById('catatan'),
|
catatan: document.getElementById('catatan'),
|
||||||
|
|
||||||
// Gallery
|
// Gallery
|
||||||
gallery: document.getElementById('gallery'),
|
gallery: document.getElementById('gallery'),
|
||||||
emptyState: document.getElementById('emptyState'),
|
emptyState: document.getElementById('emptyState'),
|
||||||
searchInput: document.getElementById('searchInput'),
|
searchInput: document.getElementById('searchInput'),
|
||||||
galleryStats: document.getElementById('galleryStats'),
|
galleryStats: document.getElementById('galleryStats'),
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
modal: document.getElementById('detailModal'),
|
modal: document.getElementById('detailModal'),
|
||||||
modalBody: document.getElementById('modalBody'),
|
modalBody: document.getElementById('modalBody'),
|
||||||
closeModal: document.querySelector('.close-modal'),
|
closeModal: document.querySelector('.close-modal'),
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
webcamStatus: document.getElementById('webcamStatus'),
|
webcamStatus: document.getElementById('webcamStatus'),
|
||||||
toast: document.getElementById('toast')
|
toast: document.getElementById('toast')
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 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);
|
async function checkApiConnection() {
|
||||||
|
try {
|
||||||
request.onerror = () => {
|
const response = await fetch(`${API_BASE_URL}/health`);
|
||||||
showToast('Gagal membuka database', 'error');
|
if (!response.ok) {
|
||||||
reject(request.error);
|
throw new Error('API not responding');
|
||||||
};
|
}
|
||||||
|
console.log('✅ API connection successful');
|
||||||
request.onsuccess = () => {
|
return true;
|
||||||
db = request.result;
|
} catch (error) {
|
||||||
console.log('Database berhasil dibuka');
|
console.error('❌ API connection failed:', error);
|
||||||
resolve(db);
|
showToast('⚠️ Tidak dapat terhubung ke server. Pastikan server berjalan.', 'error');
|
||||||
};
|
return false;
|
||||||
|
}
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 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);
|
|
||||||
};
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
request.onerror = () => {
|
throw new Error(error.error || 'Failed to save');
|
||||||
console.error('Gagal menyimpan data:', request.error);
|
}
|
||||||
reject(request.error);
|
|
||||||
};
|
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() {
|
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.getAll();
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
request.onsuccess = () => {
|
}
|
||||||
resolve(request.result);
|
|
||||||
};
|
const result = await response.json();
|
||||||
|
return result.data || [];
|
||||||
request.onerror = () => {
|
} catch (error) {
|
||||||
reject(request.error);
|
console.error('Error fetching data:', error);
|
||||||
};
|
throw error;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDataById(id) {
|
async function getDataById(id) {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const transaction = db.transaction(['dokumentasi'], 'readonly');
|
const response = await fetch(`${API_BASE_URL}/dokumentasi/${id}`);
|
||||||
const objectStore = transaction.objectStore('dokumentasi');
|
|
||||||
const request = objectStore.get(id);
|
if (!response.ok) {
|
||||||
|
throw new Error('Data not found');
|
||||||
request.onsuccess = () => {
|
}
|
||||||
resolve(request.result);
|
|
||||||
};
|
const result = await response.json();
|
||||||
|
return result.data;
|
||||||
request.onerror = () => {
|
} catch (error) {
|
||||||
reject(request.error);
|
console.error('Error fetching data by ID:', error);
|
||||||
};
|
throw error;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteData(id) {
|
async function deleteData(id) {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const transaction = db.transaction(['dokumentasi'], 'readwrite');
|
const response = await fetch(`${API_BASE_URL}/dokumentasi/${id}`, {
|
||||||
const objectStore = transaction.objectStore('dokumentasi');
|
method: 'DELETE'
|
||||||
const request = objectStore.delete(id);
|
});
|
||||||
|
|
||||||
request.onsuccess = () => {
|
if (!response.ok) {
|
||||||
resolve();
|
throw new Error('Failed to delete');
|
||||||
};
|
}
|
||||||
|
|
||||||
request.onerror = () => {
|
return true;
|
||||||
reject(request.error);
|
} catch (error) {
|
||||||
};
|
console.error('Error deleting data:', error);
|
||||||
});
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -167,7 +154,7 @@ function deleteData(id) {
|
|||||||
async function startWebcam() {
|
async function startWebcam() {
|
||||||
try {
|
try {
|
||||||
showStatus('Meminta akses webcam...', 'info');
|
showStatus('Meminta akses webcam...', 'info');
|
||||||
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
@@ -176,15 +163,15 @@ async function startWebcam() {
|
|||||||
},
|
},
|
||||||
audio: false
|
audio: false
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.webcam.srcObject = stream;
|
elements.webcam.srcObject = stream;
|
||||||
|
|
||||||
elements.startBtn.style.display = 'none';
|
elements.startBtn.style.display = 'none';
|
||||||
elements.captureBtn.style.display = 'inline-flex';
|
elements.captureBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
showStatus('Webcam aktif. Posisikan nasabah dan klik "Ambil Foto"', 'success');
|
showStatus('Webcam aktif. Posisikan nasabah dan klik "Ambil Foto"', 'success');
|
||||||
showToast('✅ Webcam berhasil diaktifkan', 'success');
|
showToast('✅ Webcam berhasil diaktifkan', 'success');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error accessing webcam:', error);
|
console.error('Error accessing webcam:', error);
|
||||||
showStatus('Gagal mengakses webcam. Pastikan webcam terhubung dan izin diberikan.', 'error');
|
showStatus('Gagal mengakses webcam. Pastikan webcam terhubung dan izin diberikan.', 'error');
|
||||||
@@ -195,41 +182,41 @@ async function startWebcam() {
|
|||||||
function capturePhoto() {
|
function capturePhoto() {
|
||||||
const canvas = elements.canvas;
|
const canvas = elements.canvas;
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
// Set canvas size to match video
|
// Set canvas size to match video
|
||||||
canvas.width = elements.webcam.videoWidth;
|
canvas.width = elements.webcam.videoWidth;
|
||||||
canvas.height = elements.webcam.videoHeight;
|
canvas.height = elements.webcam.videoHeight;
|
||||||
|
|
||||||
// Draw video frame to canvas
|
// Draw video frame to canvas
|
||||||
context.drawImage(elements.webcam, 0, 0, canvas.width, canvas.height);
|
context.drawImage(elements.webcam, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Get image data
|
// Get image data
|
||||||
capturedImage = canvas.toDataURL('image/jpeg', 0.9);
|
capturedImage = canvas.toDataURL('image/jpeg', 0.9);
|
||||||
|
|
||||||
// Show preview
|
// Show preview
|
||||||
elements.previewImage.src = capturedImage;
|
elements.previewImage.src = capturedImage;
|
||||||
elements.webcam.style.display = 'none';
|
elements.webcam.style.display = 'none';
|
||||||
elements.preview.style.display = 'block';
|
elements.preview.style.display = 'block';
|
||||||
|
|
||||||
// Update buttons
|
// Update buttons
|
||||||
elements.captureBtn.style.display = 'none';
|
elements.captureBtn.style.display = 'none';
|
||||||
elements.retakeBtn.style.display = 'inline-flex';
|
elements.retakeBtn.style.display = 'inline-flex';
|
||||||
elements.saveBtn.style.display = 'inline-flex';
|
elements.saveBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
showStatus('Foto berhasil diambil. Periksa hasilnya dan klik "Simpan" jika sudah sesuai.', 'success');
|
showStatus('Foto berhasil diambil. Periksa hasilnya dan klik "Simpan" jika sudah sesuai.', 'success');
|
||||||
showToast('📸 Foto berhasil diambil', 'success');
|
showToast('📸 Foto berhasil diambil', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
function retakePhoto() {
|
function retakePhoto() {
|
||||||
capturedImage = null;
|
capturedImage = null;
|
||||||
|
|
||||||
elements.webcam.style.display = 'block';
|
elements.webcam.style.display = 'block';
|
||||||
elements.preview.style.display = 'none';
|
elements.preview.style.display = 'none';
|
||||||
|
|
||||||
elements.retakeBtn.style.display = 'none';
|
elements.retakeBtn.style.display = 'none';
|
||||||
elements.saveBtn.style.display = 'none';
|
elements.saveBtn.style.display = 'none';
|
||||||
elements.captureBtn.style.display = 'inline-flex';
|
elements.captureBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
showStatus('Webcam aktif. Ambil foto ulang.', 'info');
|
showStatus('Webcam aktif. Ambil foto ulang.', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,16 +225,16 @@ function stopWebcam() {
|
|||||||
stream.getTracks().forEach(track => track.stop());
|
stream.getTracks().forEach(track => track.stop());
|
||||||
stream = null;
|
stream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.webcam.srcObject = null;
|
elements.webcam.srcObject = null;
|
||||||
elements.webcam.style.display = 'block';
|
elements.webcam.style.display = 'block';
|
||||||
elements.preview.style.display = 'none';
|
elements.preview.style.display = 'none';
|
||||||
|
|
||||||
elements.startBtn.style.display = 'inline-flex';
|
elements.startBtn.style.display = 'inline-flex';
|
||||||
elements.captureBtn.style.display = 'none';
|
elements.captureBtn.style.display = 'none';
|
||||||
elements.retakeBtn.style.display = 'none';
|
elements.retakeBtn.style.display = 'none';
|
||||||
elements.saveBtn.style.display = 'none';
|
elements.saveBtn.style.display = 'none';
|
||||||
|
|
||||||
capturedImage = null;
|
capturedImage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,13 +249,13 @@ async function saveDocumentation() {
|
|||||||
showToast('⚠️ Lengkapi semua data yang wajib diisi', 'error');
|
showToast('⚠️ Lengkapi semua data yang wajib diisi', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate photo
|
// Validate photo
|
||||||
if (!capturedImage) {
|
if (!capturedImage) {
|
||||||
showToast('⚠️ Ambil foto terlebih dahulu', 'error');
|
showToast('⚠️ Ambil foto terlebih dahulu', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare data
|
// Prepare data
|
||||||
const data = {
|
const data = {
|
||||||
nama: elements.nama.value.trim(),
|
nama: elements.nama.value.trim(),
|
||||||
@@ -279,21 +266,21 @@ async function saveDocumentation() {
|
|||||||
foto: capturedImage,
|
foto: capturedImage,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveToDatabase(data);
|
await saveToDatabase(data);
|
||||||
|
|
||||||
showToast('✅ Dokumentasi berhasil disimpan', 'success');
|
showToast('✅ Dokumentasi berhasil disimpan', 'success');
|
||||||
|
|
||||||
// Reset form and webcam
|
// Reset form and webcam
|
||||||
elements.form.reset();
|
elements.form.reset();
|
||||||
setDefaultDate();
|
setDefaultDate();
|
||||||
stopWebcam();
|
stopWebcam();
|
||||||
showStatus('', '');
|
showStatus('', '');
|
||||||
|
|
||||||
// Reload gallery
|
// Reload gallery
|
||||||
await loadGallery();
|
await loadGallery();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving documentation:', error);
|
console.error('Error saving documentation:', error);
|
||||||
showToast('❌ Gagal menyimpan dokumentasi', 'error');
|
showToast('❌ Gagal menyimpan dokumentasi', 'error');
|
||||||
@@ -312,32 +299,32 @@ function setDefaultDate() {
|
|||||||
async function loadGallery(searchTerm = '') {
|
async function loadGallery(searchTerm = '') {
|
||||||
try {
|
try {
|
||||||
const allData = await getAllData();
|
const allData = await getAllData();
|
||||||
|
|
||||||
// Filter by search term
|
// Filter by search term
|
||||||
let filteredData = allData;
|
let filteredData = allData;
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
filteredData = allData.filter(item =>
|
filteredData = allData.filter(item =>
|
||||||
item.nama.toLowerCase().includes(term) ||
|
item.nama.toLowerCase().includes(term) ||
|
||||||
item.noAnggota.toLowerCase().includes(term)
|
item.noAnggota.toLowerCase().includes(term)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by timestamp (newest first)
|
// Sort by timestamp (newest first)
|
||||||
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
updateGalleryStats(filteredData.length, allData.length);
|
updateGalleryStats(filteredData.length, allData.length);
|
||||||
|
|
||||||
// Show empty state if no data
|
// Show empty state if no data
|
||||||
if (filteredData.length === 0) {
|
if (filteredData.length === 0) {
|
||||||
elements.gallery.innerHTML = '';
|
elements.gallery.innerHTML = '';
|
||||||
elements.emptyState.style.display = 'block';
|
elements.emptyState.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.emptyState.style.display = 'none';
|
elements.emptyState.style.display = 'none';
|
||||||
|
|
||||||
// Render gallery items
|
// Render gallery items
|
||||||
elements.gallery.innerHTML = filteredData.map(item => `
|
elements.gallery.innerHTML = filteredData.map(item => `
|
||||||
<div class="gallery-item" data-id="${item.id}" onclick="showDetail(${item.id})">
|
<div class="gallery-item" data-id="${item.id}" onclick="showDetail(${item.id})">
|
||||||
@@ -350,7 +337,7 @@ async function loadGallery(searchTerm = '') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading gallery:', error);
|
console.error('Error loading gallery:', error);
|
||||||
showToast('❌ Gagal memuat galeri', 'error');
|
showToast('❌ Gagal memuat galeri', 'error');
|
||||||
@@ -362,12 +349,12 @@ function updateGalleryStats(shown, total) {
|
|||||||
elements.galleryStats.innerHTML = '';
|
elements.galleryStats.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let statsText = `📊 Total: ${total} dokumentasi`;
|
let statsText = `📊 Total: ${total} dokumentasi`;
|
||||||
if (shown < total) {
|
if (shown < total) {
|
||||||
statsText += ` • Ditampilkan: ${shown}`;
|
statsText += ` • Ditampilkan: ${shown}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.galleryStats.innerHTML = statsText;
|
elements.galleryStats.innerHTML = statsText;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,9 +369,9 @@ async function showDetail(id) {
|
|||||||
showToast('❌ Data tidak ditemukan', 'error');
|
showToast('❌ Data tidak ditemukan', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDetailId = id;
|
currentDetailId = id;
|
||||||
|
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div class="detail-container">
|
<div class="detail-container">
|
||||||
<div>
|
<div>
|
||||||
@@ -427,9 +414,9 @@ async function showDetail(id) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
elements.modal.classList.add('show');
|
elements.modal.classList.add('show');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error showing detail:', error);
|
console.error('Error showing detail:', error);
|
||||||
showToast('❌ Gagal memuat detail', 'error');
|
showToast('❌ Gagal memuat detail', 'error');
|
||||||
@@ -447,11 +434,11 @@ function printDetail() {
|
|||||||
|
|
||||||
async function deleteDetail() {
|
async function deleteDetail() {
|
||||||
if (!currentDetailId) return;
|
if (!currentDetailId) return;
|
||||||
|
|
||||||
if (!confirm('Apakah Anda yakin ingin menghapus dokumentasi ini?')) {
|
if (!confirm('Apakah Anda yakin ingin menghapus dokumentasi ini?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteData(currentDetailId);
|
await deleteData(currentDetailId);
|
||||||
showToast('✅ Dokumentasi berhasil dihapus', 'success');
|
showToast('✅ Dokumentasi berhasil dihapus', 'success');
|
||||||
@@ -470,30 +457,30 @@ async function deleteDetail() {
|
|||||||
async function exportData() {
|
async function exportData() {
|
||||||
try {
|
try {
|
||||||
const allData = await getAllData();
|
const allData = await getAllData();
|
||||||
|
|
||||||
if (allData.length === 0) {
|
if (allData.length === 0) {
|
||||||
showToast('⚠️ Tidak ada data untuk diekspor', 'error');
|
showToast('⚠️ Tidak ada data untuk diekspor', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CSV data (without photos for file size)
|
// Create CSV data (without photos for file size)
|
||||||
const csvData = convertToCSV(allData);
|
const csvData = convertToCSV(allData);
|
||||||
|
|
||||||
// Download CSV
|
// Download CSV
|
||||||
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
link.setAttribute('download', `dokumentasi_nasabah_${new Date().toISOString().split('T')[0]}.csv`);
|
link.setAttribute('download', `dokumentasi_nasabah_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
link.style.visibility = 'hidden';
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
showToast('✅ Data berhasil diekspor', 'success');
|
showToast('✅ Data berhasil diekspor', 'success');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error exporting data:', error);
|
console.error('Error exporting data:', error);
|
||||||
showToast('❌ Gagal mengekspor data', 'error');
|
showToast('❌ Gagal mengekspor data', 'error');
|
||||||
@@ -511,12 +498,12 @@ function convertToCSV(data) {
|
|||||||
item.catatan || '',
|
item.catatan || '',
|
||||||
formatDateTime(item.timestamp)
|
formatDateTime(item.timestamp)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
headers.join(','),
|
headers.join(','),
|
||||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return csvContent;
|
return csvContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +517,7 @@ function showStatus(message, type) {
|
|||||||
elements.webcamStatus.className = 'status-message';
|
elements.webcamStatus.className = 'status-message';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.webcamStatus.innerHTML = message;
|
elements.webcamStatus.innerHTML = message;
|
||||||
elements.webcamStatus.className = `status-message ${type}`;
|
elements.webcamStatus.className = `status-message ${type}`;
|
||||||
}
|
}
|
||||||
@@ -538,7 +525,7 @@ function showStatus(message, type) {
|
|||||||
function showToast(message, type = 'success') {
|
function showToast(message, type = 'success') {
|
||||||
elements.toast.textContent = message;
|
elements.toast.textContent = message;
|
||||||
elements.toast.className = `toast ${type} show`;
|
elements.toast.className = `toast ${type} show`;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
elements.toast.classList.remove('show');
|
elements.toast.classList.remove('show');
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -584,7 +571,7 @@ function setupEventListeners() {
|
|||||||
elements.captureBtn.addEventListener('click', capturePhoto);
|
elements.captureBtn.addEventListener('click', capturePhoto);
|
||||||
elements.retakeBtn.addEventListener('click', retakePhoto);
|
elements.retakeBtn.addEventListener('click', retakePhoto);
|
||||||
elements.saveBtn.addEventListener('click', saveDocumentation);
|
elements.saveBtn.addEventListener('click', saveDocumentation);
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
elements.closeModal.addEventListener('click', closeModal);
|
elements.closeModal.addEventListener('click', closeModal);
|
||||||
elements.modal.addEventListener('click', (e) => {
|
elements.modal.addEventListener('click', (e) => {
|
||||||
@@ -592,18 +579,18 @@ function setupEventListeners() {
|
|||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.printBtn.addEventListener('click', printDetail);
|
elements.printBtn.addEventListener('click', printDetail);
|
||||||
elements.deleteBtn.addEventListener('click', deleteDetail);
|
elements.deleteBtn.addEventListener('click', deleteDetail);
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
elements.searchInput.addEventListener('input', (e) => {
|
elements.searchInput.addEventListener('input', (e) => {
|
||||||
loadGallery(e.target.value);
|
loadGallery(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
elements.exportBtn.addEventListener('click', exportData);
|
elements.exportBtn.addEventListener('click', exportData);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -618,20 +605,20 @@ 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();
|
||||||
|
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
// Load gallery
|
// Load gallery
|
||||||
await loadGallery();
|
await loadGallery();
|
||||||
|
|
||||||
console.log('✅ Aplikasi berhasil diinisialisasi');
|
console.log('✅ Aplikasi berhasil diinisialisasi');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing app:', error);
|
console.error('Error initializing app:', error);
|
||||||
showToast('❌ Gagal menginisialisasi aplikasi', 'error');
|
showToast('❌ Gagal menginisialisasi aplikasi', 'error');
|
||||||
|
|||||||
630
app.js.backup
Normal file
630
app.js.backup
Normal 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
34
database.sql
Normal 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;
|
||||||
@@ -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
1357
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
265
server.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user