Upload source code tugas akhir

This commit is contained in:
roihanu 2026-07-11 18:09:05 +07:00
commit 7d2522b310
14 changed files with 2819 additions and 0 deletions

71
FIREBASE_STRUCTURE.md Normal file
View File

@ -0,0 +1,71 @@
# Struktur Firebase Realtime Database
# Smart Donation Box
```
smart_donation_box/
├── summary/
│ ├── total_donasi : number → Total semua donasi masuk (Rp)
│ ├── donasi_hari_ini : number → Donasi hari ini (reset tiap hari)
│ ├── total_pengeluaran : number → Total pengeluaran dana
│ └── saldo : number → total_donasi - total_pengeluaran
├── donasi_log/
│ └── {push_id}/
│ ├── nominal : number → Nominal uang terdeteksi (misal: 2000)
│ ├── confidence : number → Nilai confidence YOLO (0.0 - 1.0)
│ └── timestamp : number → Unix timestamp ms (Date.now())
└── pengeluaran_log/
└── {push_id}/
├── nominal : number → Nominal pengeluaran (Rp)
├── keterangan : string → Keterangan pengeluaran
└── timestamp : number → Unix timestamp ms (Date.now())
```
## Contoh Data Nyata di Firebase
```json
{
"smart_donation_box": {
"summary": {
"total_donasi": 75000,
"donasi_hari_ini": 25000,
"total_pengeluaran": 20000,
"saldo": 55000
},
"donasi_log": {
"-OAbc123xyz": {
"nominal": 2000,
"confidence": 0.95,
"timestamp": 1744905600000
},
"-OAbc456abc": {
"nominal": 5000,
"confidence": 0.88,
"timestamp": 1744906200000
}
},
"pengeluaran_log": {
"-OAxyz789": {
"nominal": 20000,
"keterangan": "Pembelian alat kebersihan",
"timestamp": 1744820000000
}
}
}
}
```
## Rules Firebase (Pasang di Rules tab)
```json
{
"rules": {
"smart_donation_box": {
".read": true,
".write": true
}
}
}
```

85
SETUP.md Normal file
View File

@ -0,0 +1,85 @@
# CARA SETUP FOTO DETEKSI REALTIME
# ============================================================
## Yang perlu dilakukan (3 langkah saja):
---
## LANGKAH 1 — Aktifkan Firebase Storage
1. Buka https://console.firebase.google.com
2. Pilih project: smart-donation-adff1
3. Di menu kiri klik: **Storage** → klik **Get Started**
4. Pilih region: **asia-southeast1** → klik Next → Done
5. Setelah Storage aktif, klik tab **Rules** dan ganti dengan:
```
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read, write: if true;
}
}
}
```
6. Klik **Publish**
---
## LANGKAH 2 — Install library baru di Python
Jalankan perintah ini di terminal:
```bash
pip install firebase-admin opencv-python ultralytics
```
Kalau pakai conda/Anaconda:
```bash
pip install firebase-admin --break-system-packages
```
Library `firebase-admin` sudah include Storage, tidak perlu install tambahan.
---
## LANGKAH 3 — Ganti file
Ganti file lama dengan file baru dari folder ini:
- `main.py` → ganti main.py yang lama
- `index.html` → ganti web/index.html yang lama
ESP32 **tidak perlu diubah** sama sekali.
---
## Cara Kerja Setelah Setup
```
ESP32-CAM kirim foto
Flask terima + YOLO deteksi
Flask upload foto (overwrite 1 file) ke Firebase Storage
Flask simpan URL foto ke Realtime DB: smart_donation_box/last_detection
Dashboard baca URL dari Realtime DB → tampilkan foto otomatis
```
Hanya 1 file foto yang tersimpan di Storage (last_detection.jpg),
selalu di-overwrite → Storage tidak penuh.
---
## Struktur Firebase Realtime DB (tambahan baru)
```
smart_donation_box/
└── last_detection/ ← NODE BARU
├── foto_url : string → URL publik foto di Firebase Storage
├── label : string → Hasil deteksi (misal: "Rp 2000, Rp 5000")
└── timestamp : number → Waktu deteksi (Unix ms)
```

BIN
best.pt Normal file

Binary file not shown.

View File

@ -0,0 +1,773 @@
/*
*
* SMART DONATION BOX ESP32-CAM + OLED SH1106 1.3"
* Robot Eyes "Cozmo Style" FluxGarage RoboEyes Library
*
* WIRING:
* OLED VCC 3V3 (stepdown) OLED GND GND
* OLED SCL GPIO14 OLED SDA GPIO15
* IR OUT GPIO13 Button GPIO12 GND
* Flash GPIO4 (onboard)
*
* Library:
* - WiFiManager by tzapu
* - FluxGarage RoboEyes (Adafruit GFX based)
* - Adafruit SH110X (untuk SH1106)
*
* FIX:
* - eyes.begin(w, h, fps) 3 argumen sesuai versi terbaru
* - robotState dideklarasi global sebelum setup/loop
* - SAD diganti ST_SAD agar tidak konflik dengan SDA macro
*
*/
#include "esp_camera.h"
#include <WiFi.h>
#include <WiFiManager.h>
#include <HTTPClient.h>
#include <Wire.h>
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#include <Preferences.h>
// ── Adafruit GFX + SH1106 ────────────────────────────────────
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
// ── RoboEyes ─────────────────────────────────────────────────
#include <FluxGarage_RoboEyes.h>
// ════════════════════════════════════════════════════
// DISPLAY SETUP — SH1106 via I2C
// SCL = GPIO14, SDA = GPIO15
// ════════════════════════════════════════════════════
#define SCREEN_W 128
#define SCREEN_H 64
#define OLED_ADDR 0x3C
Adafruit_SH1106G display(SCREEN_W, SCREEN_H, &Wire, -1);
// RoboEyes: konstruktor hanya butuh referensi display
// begin() dipanggil terpisah dengan (width, height, fps)
RoboEyes<Adafruit_SH1106G> eyes(display);
// ════════════════════════════════════════════════════
// CONFIG
// ════════════════════════════════════════════════════
#define DEFAULT_SERVER_IP "192.168.0.101"
#define DEFAULT_SERVER_PORT 5000
#define CAPTURE_DELAY_MS 3200
#define FLASH_PRE_BR 80
#define FLASH_CAPTURE_BR 140 // <--- PERUBAHAN DI SINI: Diubah ke 140 agar tidak terlalu terang
#define FLASH_STAB_MS 600
// Camera pins AI-Thinker
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
#define FLASH_GPIO_NUM 4
#define IR_PIN 13
#define RESET_WIFI_PIN 12
// ════════════════════════════════════════════════════
// STATE — pakai prefix ST_ agar tidak konflik
// dengan macro SDA / konstanta lain di ESP32
// ════════════════════════════════════════════════════
enum RobotState {
ST_IDLE,
ST_SCANNING,
ST_HAPPY,
ST_SAD, // tidak konflik karena pakai prefix ST_
ST_WIFI,
ST_BOOT
};
// !! DEKLARASI GLOBAL — wajib di sini, sebelum setup/loop !!
RobotState robotState = ST_BOOT;
// ════════════════════════════════════════════════════
// GLOBALS
// ════════════════════════════════════════════════════
String serverUrl;
char serverIpBuf[32] = DEFAULT_SERVER_IP;
Preferences prefs;
unsigned long lastIRTime = 0;
const int DEBOUNCE_MS = 1500;
unsigned long btnPressTime = 0;
bool btnPressed = false;
// Scanning animation vars
static int scanY = 0;
static int scanDots = 0;
static unsigned long scanTimer = 0;
// Happy frame counter
static int happyFrame = 0;
static unsigned long happyTimer = 0;
static String happyNominal = "";
// Idle random position timer
static unsigned long idlePosTimer = 0;
// ════════════════════════════════════════════════════
// HELPER: Init ulang RoboEyes ke ukuran normal
// ════════════════════════════════════════════════════
void resetEyesNormal() {
eyes.setWidth(38, 38);
eyes.setHeight(36, 36);
eyes.setBorderradius(8, 8);
eyes.setSpacebetween(12);
eyes.setMood(DEFAULT);
eyes.setAutoblinker(ON, 3, 2);
eyes.setIdleMode(ON, 4, 2);
eyes.setCuriosity(ON);
}
// ════════════════════════════════════════════════════
// INISIALISASI DISPLAY + ROBOEYES
// ════════════════════════════════════════════════════
void initDisplay() {
Wire.begin(15, 14); // SDA=GPIO15, SCL=GPIO14
if (!display.begin(OLED_ADDR, true)) {
Serial.println("OLED gagal init! Lanjut...");
}
display.setContrast(200);
display.clearDisplay();
display.display();
// FIX: begin() versi terbaru RoboEyes = 3 argumen saja
// Tidak ada argumen display — sudah di-pass lewat konstruktor
eyes.begin(SCREEN_W, SCREEN_H, 30);
resetEyesNormal();
}
// ════════════════════════════════════════════════════
// SUSPEND / RESUME OLED (saat capture kamera)
// ════════════════════════════════════════════════════
void suspendOLED() {
Wire.end();
}
void resumeOLED() {
Wire.begin(15, 14);
if (!display.begin(OLED_ADDR, true)) {
Serial.println("OLED resume gagal!");
}
display.setContrast(200);
// Re-init RoboEyes setelah Wire restart
eyes.begin(SCREEN_W, SCREEN_H, 30);
resetEyesNormal();
}
// ════════════════════════════════════════════════════
// HELPER: teks centered di bawah area mata
// ════════════════════════════════════════════════════
void drawSubText(const char* line1, const char* line2 = nullptr) {
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
int y1 = 50;
if (line1) {
int16_t bx, by; uint16_t bw, bh;
display.getTextBounds(line1, 0, y1, &bx, &by, &bw, &bh);
display.setCursor((SCREEN_W - bw) / 2, y1);
display.print(line1);
}
if (line2) {
int16_t bx, by; uint16_t bw, bh;
display.getTextBounds(line2, 0, y1 + 10, &bx, &by, &bw, &bh);
display.setCursor((SCREEN_W - bw) / 2, y1 + 10);
display.print(line2);
}
}
// ════════════════════════════════════════════════════
// SCREEN: BOOTING
// ════════════════════════════════════════════════════
void showBooting(const char* msg) {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(10, 2);
display.print("Smart Donation Box");
display.drawLine(0, 12, 128, 12, SH110X_WHITE);
// Mata sederhana saat booting
display.drawCircle(42, 32, 14, SH110X_WHITE);
display.fillCircle(42, 32, 8, SH110X_WHITE);
display.drawCircle(86, 32, 14, SH110X_WHITE);
display.fillCircle(86, 32, 8, SH110X_WHITE);
display.setCursor(4, 54);
display.print(msg);
display.display();
}
// ════════════════════════════════════════════════════
// SCREEN: WiFi Setup
// ════════════════════════════════════════════════════
void showWiFiSetup() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(4, 2); display.print("[ WiFi Config Mode ]");
display.drawLine(0, 12, 128, 12, SH110X_WHITE);
display.setCursor(0, 16); display.print("SSID: SmartDonationBox");
display.setCursor(0, 26); display.print("Pass: donasi123");
display.setCursor(0, 36); display.print("URL : 192.168.4.1");
display.setCursor(0, 49); display.print("Buka browser, setting");
display.setCursor(0, 58); display.print("IP Flask di sana :)");
display.display();
}
// ════════════════════════════════════════════════════
// SCREEN: WiFi OK
// ════════════════════════════════════════════════════
void showWiFiOK() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(20, 2); display.print("WiFi Terhubung!");
display.drawLine(0, 12, 128, 12, SH110X_WHITE);
display.setCursor(0, 16); display.print("SSID:"); display.print(WiFi.SSID());
display.setCursor(0, 26); display.print("IP :"); display.print(WiFi.localIP());
display.setCursor(0, 36); display.print("Srv :"); display.print(serverIpBuf);
display.drawLine(108, 52, 112, 58, SH110X_WHITE);
display.drawLine(112, 58, 124, 44, SH110X_WHITE);
display.display();
}
// ════════════════════════════════════════════════════
// SCREEN: Reset WiFi
// ════════════════════════════════════════════════════
void showResetWiFi() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(22, 20); display.print("Reset WiFi...");
display.setCursor(12, 32); display.print("Menghapus data..");
display.display();
}
// ════════════════════════════════════════════════════
// SCREEN: WiFi Reconnect
// ════════════════════════════════════════════════════
void showWiFiReconnect() {
eyes.setMood(ANGRY);
eyes.setAutoblinker(OFF, 0, 0);
eyes.setIdleMode(OFF, 0, 0);
display.clearDisplay();
eyes.drawEyes();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(4, 56); display.print("WiFi terputus...");
display.display();
}
// ════════════════════════════════════════════════════
// SCREEN: IDLE — mata Cozmo hidup
// ════════════════════════════════════════════════════
void showIdleRobot() {
unsigned long now = millis();
eyes.setMood(DEFAULT);
eyes.setAutoblinker(ON, 3, 2);
eyes.setIdleMode(ON, 4, 2);
// Sesekali ganti ekspresi random
if (now - idlePosTimer > 8000 + (unsigned long)random(7000)) {
idlePosTimer = now;
int r = random(6);
if (r == 0) eyes.setPosition(N);
else if (r == 1) eyes.setPosition(E);
else if (r == 2) eyes.setPosition(W);
else if (r == 3) eyes.setMood(HAPPY);
else if (r == 4) eyes.setMood(TIRED);
else eyes.setPosition(DEFAULT);
}
// Update animasi RoboEyes (blink + look otomatis)
eyes.update();
// Banner "YUK DONASI" sesekali
static bool showBanner = false;
static unsigned long bannerDelay = 8000;
static unsigned long bannerStart = 0;
if (!showBanner && now > bannerDelay && now - bannerDelay > 8000) {
showBanner = true;
bannerStart = now;
eyes.setPosition(S);
eyes.setIdleMode(OFF, 0, 0);
}
if (showBanner) {
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(22, 4);
display.print(" YUK DONASI :) ");
display.display();
if (now - bannerStart > 2500) {
showBanner = false;
bannerDelay = now;
eyes.setPosition(DEFAULT);
eyes.setIdleMode(ON, 4, 2);
}
}
}
// ════════════════════════════════════════════════════
// SCREEN: SCANNING
// ════════════════════════════════════════════════════
void showScanningRobot() {
unsigned long now = millis();
eyes.setMood(DEFAULT);
eyes.setAutoblinker(OFF, 0, 0);
eyes.setIdleMode(OFF, 0, 0);
eyes.setWidth(28, 28);
eyes.setHeight(24, 24);
eyes.setBorderradius(4, 4);
eyes.setPosition(DEFAULT);
display.clearDisplay();
eyes.drawEyes();
// Scan line
int lineY = 14 + (scanY % 30);
if (lineY < 44) {
display.drawLine(0, lineY, 128, lineY, SH110X_WHITE);
}
// Progress bar
display.drawRoundRect(4, 52, 120, 8, 3, SH110X_WHITE);
int fill = (scanY * 120) / 30;
if (fill > 4) display.fillRoundRect(4, 52, fill, 8, 3, SH110X_WHITE);
// Teks dots
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
char dot[12] = "Scanning";
for (int d = 0; d <= scanDots; d++) strcat(dot, ".");
display.setCursor(4, 42);
display.print(dot);
display.display();
if (now - scanTimer > 80) {
scanY = (scanY + 1) % 30;
scanDots = (scanDots + 1) % 3;
scanTimer = now;
}
}
// ════════════════════════════════════════════════════
// SCREEN: HAPPY
// ════════════════════════════════════════════════════
void showHappyRobot(String nominal) {
unsigned long now = millis();
eyes.setWidth(38, 38);
eyes.setHeight(36, 36);
eyes.setBorderradius(8, 8);
eyes.setSpacebetween(12);
eyes.setMood(HAPPY);
eyes.setAutoblinker(ON, 2, 1);
eyes.setIdleMode(OFF, 0, 0);
if (happyFrame % 6 < 3) eyes.setPosition(N);
else eyes.setPosition(DEFAULT);
display.clearDisplay();
eyes.drawEyes();
// Blush
if (happyFrame % 4 < 3) {
display.drawCircle(14, 34, 6, SH110X_WHITE);
display.drawCircle(14, 34, 5, SH110X_WHITE);
display.drawCircle(114, 34, 6, SH110X_WHITE);
display.drawCircle(114, 34, 5, SH110X_WHITE);
}
// Sparkle
if (happyFrame % 4 < 2) {
display.drawLine(6, 6, 10, 10, SH110X_WHITE);
display.drawLine(10, 6, 6, 10, SH110X_WHITE);
display.drawLine(118, 6, 122, 10, SH110X_WHITE);
display.drawLine(122, 6, 118, 10, SH110X_WHITE);
}
// Teks "Terima Kasih!"
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
const char* tk = "Terima Kasih!";
int16_t bx, by; uint16_t bw, bh;
display.getTextBounds(tk, 0, 44, &bx, &by, &bw, &bh);
display.setCursor((128 - bw) / 2, 44);
display.print(tk);
// Nominal
if (nominal.startsWith("Berhasil: ")) nominal = nominal.substring(10);
const char* nom = nominal.c_str();
display.getTextBounds(nom, 0, 53, &bx, &by, &bw, &bh);
display.setCursor(max(0, (128 - (int)bw) / 2), 53);
display.print(nom);
display.display();
if (now - happyTimer > 180) {
happyFrame++;
happyTimer = now;
}
}
// ════════════════════════════════════════════════════
// SCREEN: SAD
// ════════════════════════════════════════════════════
void showSadRobot() {
eyes.setWidth(38, 38);
eyes.setHeight(36, 36);
eyes.setBorderradius(8, 8);
eyes.setSpacebetween(12);
eyes.setMood(TIRED);
eyes.setAutoblinker(OFF, 0, 0);
eyes.setIdleMode(OFF, 0, 0);
eyes.setPosition(S);
display.clearDisplay();
eyes.drawEyes();
// Air mata
display.fillCircle(38, 48, 2, SH110X_WHITE);
display.fillCircle(38, 52, 1, SH110X_WHITE);
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(8, 4);
display.print("Gagal, Coba lagi :(");
display.display();
}
// ════════════════════════════════════════════════════
// FLASH
// ════════════════════════════════════════════════════
void initFlash() {
ledcAttach(FLASH_GPIO_NUM, 5000, 8);
ledcWrite(FLASH_GPIO_NUM, 0);
}
void flashSequence() {
ledcWrite(FLASH_GPIO_NUM, FLASH_PRE_BR);
delay(80);
ledcWrite(FLASH_GPIO_NUM, 0);
delay(120);
ledcWrite(FLASH_GPIO_NUM, FLASH_PRE_BR);
delay(80);
ledcWrite(FLASH_GPIO_NUM, 0);
delay(200);
camera_fb_t* tmp;
for (int i = 0; i < 5; i++) {
tmp = esp_camera_fb_get();
if (tmp) esp_camera_fb_return(tmp);
delay(25);
}
// Fade in ke kecerahan maksimal yang baru (140)
for (int b = 0; b <= FLASH_CAPTURE_BR; b += 15) {
ledcWrite(FLASH_GPIO_NUM, b);
delay(15);
}
ledcWrite(FLASH_GPIO_NUM, FLASH_CAPTURE_BR);
delay(FLASH_STAB_MS);
}
// ════════════════════════════════════════════════════
// CAMERA
// ════════════════════════════════════════════════════
bool initCamera() {
camera_config_t cfg;
cfg.ledc_channel = LEDC_CHANNEL_0;
cfg.ledc_timer = LEDC_TIMER_0;
cfg.pin_d0=Y2_GPIO_NUM; cfg.pin_d1=Y3_GPIO_NUM;
cfg.pin_d2=Y4_GPIO_NUM; cfg.pin_d3=Y5_GPIO_NUM;
cfg.pin_d4=Y6_GPIO_NUM; cfg.pin_d5=Y7_GPIO_NUM;
cfg.pin_d6=Y8_GPIO_NUM; cfg.pin_d7=Y9_GPIO_NUM;
cfg.pin_xclk=XCLK_GPIO_NUM; cfg.pin_pclk=PCLK_GPIO_NUM;
cfg.pin_vsync=VSYNC_GPIO_NUM; cfg.pin_href=HREF_GPIO_NUM;
cfg.pin_sscb_sda=SIOD_GPIO_NUM; cfg.pin_sscb_scl=SIOC_GPIO_NUM;
cfg.pin_pwdn=PWDN_GPIO_NUM; cfg.pin_reset=RESET_GPIO_NUM;
cfg.xclk_freq_hz = 20000000;
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = FRAMESIZE_VGA;
cfg.jpeg_quality = 10;
cfg.fb_count = 1;
if (esp_camera_init(&cfg) != ESP_OK) {
Serial.println("Kamera gagal!"); return false;
}
sensor_t* s = esp_camera_sensor_get();
if (s) {
s->set_vflip(s, 1);
s->set_hmirror(s, 1);
s->set_exposure_ctrl(s, 1);
s->set_aec2(s, 1);
s->set_ae_level(s, -1);
s->set_gain_ctrl(s, 1);
s->set_agc_gain(s, 0);
s->set_gainceiling(s, (gainceiling_t)1);
s->set_whitebal(s, 1);
s->set_awb_gain(s, 1);
s->set_wb_mode(s, 1);
s->set_brightness(s, 0);
s->set_contrast(s, 1);
s->set_saturation(s, -1);
s->set_sharpness(s, 2);
s->set_denoise(s, 1);
s->set_bpc(s, 1);
s->set_wpc(s, 1);
s->set_raw_gma(s, 1);
s->set_lenc(s, 1);
s->set_special_effect(s, 0);
s->set_colorbar(s, 0);
}
camera_fb_t* fb;
for (int i = 0; i < 10; i++) {
fb = esp_camera_fb_get();
if (fb) esp_camera_fb_return(fb);
delay(80);
}
Serial.println("Kamera OK");
return true;
}
// ════════════════════════════════════════════════════
// BUTTON RESET
// ════════════════════════════════════════════════════
void handleResetButton() {
int state = digitalRead(RESET_WIFI_PIN);
if (state == LOW && !btnPressed) {
btnPressed = true;
btnPressTime = millis();
}
if (state == HIGH && btnPressed) {
btnPressed = false;
unsigned long dur = millis() - btnPressTime;
if (dur >= 50 && dur < 5000) {
showResetWiFi();
WiFiManager wm;
wm.resetSettings();
prefs.begin("sdb", false);
prefs.clear();
prefs.end();
delay(1000);
ESP.restart();
}
}
}
// ════════════════════════════════════════════════════
// SETUP
// ════════════════════════════════════════════════════
void setup() {
WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);
Serial.begin(115200);
delay(300);
Serial.println("\n=== Smart Donation Box (Cozmo Eyes) ===");
initDisplay();
showBooting("Inisialisasi...");
initFlash();
pinMode(IR_PIN, INPUT_PULLUP);
pinMode(RESET_WIFI_PIN, INPUT_PULLUP);
prefs.begin("sdb", true);
String savedIp = prefs.getString("server_ip", DEFAULT_SERVER_IP);
prefs.end();
savedIp.toCharArray(serverIpBuf, sizeof(serverIpBuf));
if (digitalRead(RESET_WIFI_PIN) == LOW) {
showResetWiFi();
WiFiManager wm;
wm.resetSettings();
prefs.begin("sdb", false);
prefs.clear();
prefs.end();
delay(2000);
ESP.restart();
}
showBooting("Konek WiFi...");
WiFi.setSleep(false);
WiFiManager wm;
wm.setConfigPortalTimeout(180);
wm.setDebugOutput(false);
WiFiManagerParameter paramIp("server_ip", "IP Server Flask", serverIpBuf, 32);
wm.addParameter(&paramIp);
wm.setAPCallback([](WiFiManager*) { showWiFiSetup(); });
wm.setSaveConfigCallback([&]() {
String ip = String(paramIp.getValue());
ip.trim();
if (ip.length() >= 7) {
prefs.begin("sdb", false);
prefs.putString("server_ip", ip);
prefs.end();
ip.toCharArray(serverIpBuf, sizeof(serverIpBuf));
}
});
if (!wm.autoConnect("SmartDonationBox", "donasi123")) {
showBooting("WiFi GAGAL! Restart...");
delay(3000);
ESP.restart();
}
serverUrl = "http://" + String(serverIpBuf) + ":" +
String(DEFAULT_SERVER_PORT) + "/predict";
showWiFiOK();
delay(2500);
showBooting("Init kamera...");
if (!initCamera()) {
showBooting("KAMERA GAGAL!");
while (true) delay(1000);
}
resetEyesNormal();
idlePosTimer = millis();
// !! FIX: set robotState global di sini !!
robotState = ST_IDLE;
Serial.println("Sistem siap!");
}
// ════════════════════════════════════════════════════
// LOOP
// ════════════════════════════════════════════════════
void loop() {
yield();
handleResetButton();
if (WiFi.status() != WL_CONNECTED) {
showWiFiReconnect();
WiFi.reconnect();
delay(3000);
return;
}
bool irDetect = (digitalRead(IR_PIN) == LOW);
if (irDetect && (millis() - lastIRTime > DEBOUNCE_MS)) {
lastIRTime = millis();
Serial.println("\n>>> IR terdeteksi!");
robotState = ST_SCANNING;
scanY = 0; scanDots = 0; scanTimer = millis();
for (int i = 0; i < 14; i++) {
showScanningRobot();
delay(90);
}
suspendOLED();
flashSequence();
camera_fb_t* fb = esp_camera_fb_get();
ledcWrite(FLASH_GPIO_NUM, 0);
resumeOLED();
if (!fb) {
Serial.println("Capture gagal!");
robotState = ST_SAD;
unsigned long ts = millis();
while (millis() - ts < CAPTURE_DELAY_MS) {
showSadRobot();
delay(100);
}
} else {
Serial.printf("Foto: %d KB\n", fb->len / 1024);
for (int i = 0; i < 5; i++) {
showScanningRobot();
delay(80);
}
HTTPClient http;
http.begin(serverUrl);
http.addHeader("Content-Type", "image/jpeg");
http.setTimeout(15000);
int code = http.POST(fb->buf, fb->len);
String resp = "";
if (code == 200) {
resp = http.getString();
Serial.println("Prediksi: " + resp);
} else {
Serial.printf("Error HTTP: %d\n", code);
}
http.end();
esp_camera_fb_return(fb);
bool ok = !resp.isEmpty() && resp != "ERROR" &&
resp != "Tidak ada objek terdeteksi";
if (!ok) {
robotState = ST_SAD;
unsigned long ts = millis();
while (millis() - ts < CAPTURE_DELAY_MS) {
showSadRobot();
delay(100);
}
} else {
robotState = ST_HAPPY;
happyFrame = 0;
happyTimer = millis();
happyNominal = resp;
unsigned long hs = millis();
while (millis() - hs < (unsigned long)CAPTURE_DELAY_MS) {
showHappyRobot(happyNominal);
delay(20);
}
}
}
// Kembali idle
robotState = ST_IDLE;
resetEyesNormal();
idlePosTimer = millis() + 2000;
} else {
showIdleRobot();
}
delay(20);
}

157
main.py Normal file
View File

@ -0,0 +1,157 @@
import cv2
import numpy as np
from flask import Flask, request
from ultralytics import YOLO
import firebase_admin
from firebase_admin import credentials, db
import time
import base64
# ── 1. Inisialisasi Firebase ──────────────────────────────────────────────────
cred = credentials.Certificate("serviceAccountKey.json")
firebase_admin.initialize_app(cred, {
'databaseURL': 'https://smart-donation-adff1-default-rtdb.asia-southeast1.firebasedatabase.app/'
# Firebase Storage TIDAK dipakai — foto disimpan sebagai base64 di Realtime DB
# Jika suatu saat upgrade ke Blaze, tambahkan:
# 'storageBucket': 'smart-donation-adff1.appspot.com'
})
# ── 2. Load Model YOLO ────────────────────────────────────────────────────────
model = YOLO("best.pt")
app = Flask(__name__)
print("Class model:", model.names) # Verifikasi nama class saat startup
NOMINAL_MAP = {
'1': 1000,
'2': 2000,
'5': 5000,
'10': 10000,
'20': 20000,
'50': 50000,
'100': 100000,
}
# ── 3. Helper: Simpan foto sebagai Base64 ke Realtime Database ───────────────
def simpan_foto_terakhir(img_bgr, deteksi_label):
"""
Encode foto hasil deteksi ke Base64 lalu simpan langsung ke Realtime DB.
Tidak butuh Firebase Storage kompatibel dengan plan Spark (gratis).
Dashboard web bisa langsung baca via <img src="data:image/jpeg;base64,...">
"""
try:
# Render bounding box dari YOLO
results = model(img_bgr, conf=0.7, verbose=False)
annotated = results[0].plot()
# Encode ke JPEG bytes lalu konversi ke Base64 string
# Quality 60 → ukuran ~30-60KB, cukup untuk preview dashboard
_, buffer = cv2.imencode('.jpg', annotated, [cv2.IMWRITE_JPEG_QUALITY, 60])
img_base64 = base64.b64encode(buffer).decode('utf-8')
foto_data = "data:image/jpeg;base64," + img_base64
# Simpan ke Realtime DB — selalu overwrite node yang sama
db.reference('smart_donation_box/last_detection').set({
'foto_url': foto_data, # Dashboard langsung pakai nilai ini di <img src>
'label': deteksi_label,
'timestamp': int(time.time() * 1000)
})
print(f"📸 Foto tersimpan ke Realtime DB ({len(img_base64) // 1024} KB)")
except Exception as e:
print(f"⚠️ Gagal simpan foto: {e}")
# ── 4. Helper: Catat transaksi donasi & update summary ───────────────────────
def update_firebase(nominal, confidence):
ref_log = db.reference('smart_donation_box/donasi_log')
ref_summary = db.reference('smart_donation_box/summary')
# Tambah entri baru di donasi_log
ref_log.push({
'nominal': nominal,
'confidence': round(float(confidence), 2),
'timestamp': int(time.time() * 1000)
})
# Update summary (total & saldo)
summary = ref_summary.get() or {}
old_total = summary.get('total_donasi', 0)
old_pengeluaran = summary.get('total_pengeluaran', 0)
new_total = old_total + nominal
new_saldo = new_total - old_pengeluaran
ref_summary.update({
'total_donasi': new_total,
'donasi_hari_ini': summary.get('donasi_hari_ini', 0) + nominal,
'saldo': new_saldo
})
print(f"✅ Donasi Rp {nominal:,} | Confidence {confidence:.0%} | Total Rp {new_total:,}")
# ── 5. Endpoint /predict ──────────────────────────────────────────────────────
@app.route('/predict', methods=['POST'])
def predict():
try:
# Terima raw JPEG bytes dari ESP32-CAM
file = request.data
nparr = np.frombuffer(file, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
print("❌ Gagal decode gambar dari ESP32")
return "Gagal decode gambar", 400
print(f"📥 Frame diterima: {len(file) // 1024} KB")
# Jalankan prediksi YOLO
results = model.predict(img, conf=0.7, verbose=False)
detections = []
for r in results:
for box in r.boxes:
label = model.names[int(box.cls[0])]
conf = float(box.conf[0])
nominal = NOMINAL_MAP.get(label)
if nominal is not None:
update_firebase(nominal, conf)
detections.append(f"Rp {nominal:,}")
print(f"🎯 Terdeteksi: label='{label}' → Rp {nominal:,} ({conf:.0%})")
else:
print(f"⚠️ Label '{label}' tidak ada di NOMINAL_MAP, dilewati")
continue
# Simpan foto ke DB (selalu, baik ada deteksi maupun tidak)
label_str = ', '.join(detections) if detections else 'Tidak ada deteksi'
simpan_foto_terakhir(img, label_str)
if not detections:
print("🔍 Tidak ada uang terdeteksi")
return "Tidak ada objek terdeteksi", 200
print(f"🎯 Terdeteksi: {label_str}")
return f"Berhasil: {label_str}", 200
except Exception as e:
print(f"❌ Error di /predict: {e}")
return str(e), 500
# ── 6. Health check endpoint (opsional, untuk test koneksi) ──────────────────
@app.route('/ping', methods=['GET'])
def ping():
return "Smart Donation Box Server OK", 200
# ── 7. Jalankan Server ────────────────────────────────────────────────────────
if __name__ == '__main__':
print("=" * 50)
print(" Smart Donation Box — Flask Server")
print(" Listening on http://0.0.0.0:5000")
print(" Endpoint: POST /predict")
print("=" * 50)
app.run(host='0.0.0.0', port=5000, debug=False)

13
serviceAccountKey.json Normal file
View File

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "smart-donation-adff1",
"private_key_id": "5d2683636767f0f2654e0455db5a7aaeb4b74aab",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDLA2cU0hvW68qy\n1QLpaJpAxYBUEo+e2ewMrblnYGA1+K4BcmBLi1NV6RjLterC7XZ/j7bY/0nfwJwC\nJEf4bmeDo3dE9RlASIIdEBoXyn+7+7ceqcvi9qJWz3v+poGFzMQLHbnxIua/vGKs\nxmGIEZ9mtkn8/LNh6xLjmLPUzgaumo3kKPp1OgONeNop3YX8/Cg2G3J1yvBrP01J\njIosf+Q6uEa8urSV/Lf2303mFvkjHrRXyzCoaw+S+WX2FyJeKhXOAMxaMpvClTUN\ndqvSNx9fvlHalx2X/yJsUZxKuCXbWAXCgxqm1bEuErxWtI1jAuaQ3gAWOlcgtFbG\nYCsir1ZfAgMBAAECggEAFUpZECTsyIBX9KNEN+hBbROJXsfHihg+miHJ9N51mJNo\n5Dvzf1iwN61HbW25nhG7QKt4uI8lVzLi6ZKWfaZOcs683l159XTlOFkM83vII5XF\nYZDbEgVdhw8haNZbdQXBdYz7iB9p5afDDW2MovF+Lw5k+gQiZ914UKgeC+5DYyIg\nOuAS8aM36F9jZVcTo/nfJ3M4GAfPlrNJ6lX/Ac2JpT9LCib7Gjx/VyXw89aeOQe1\nZ3hiBLlHdnwKGdwaaV4G9/jpHc3DeH2bYebQECBhxmonL/pvgH6uQEWaR+IP3Wdj\nP9dpfjVC8cuidvS5R2Mo13aLflvOY8GpZMvcfSDjEQKBgQD7+kesj8zHeALEMgxh\nmsqduqJmYXBfgRJxvZVDIjREr7xOUxPJiaPsbekjkx7yOPjeAUPSNmrKen/3c6IH\niDlX0xv2/VL+tUzNukL0Op+qKd/ZUderi5GLflEBCoMIglks/9tx/UmZxcZiAtkG\nSddNgbUw1pltVEM1QwdtvOBA9wKBgQDOQQb3yppIk65Wc/tE+RAWjpqft9XUzB8N\nnvcdoPovI40TI7z/CrkOvf25Br1ly95OdBxp/OwJqGqC0CXCGGkJvan6xQVov1JQ\n66XE91Zti4pOlsROl4CYWMuoV/7kYRshWLyKxFZNAQcGr4QQA8+zVSHrom8pD4Wv\njoxtpt2j2QKBgFWX4Pz3JAKl+5qVvaryH92QZlYMx1VJzPAiDC/v0H18jKS2h8Qw\nIyTO+SECesKhI7iWCWnA3mnFTu7JssKIPaJdreQqjSTEhUBlAxVfTJlK/CUgLfix\nwlD2KaIG5hff31bv2qVP+s5nZkoo2XvPXXmQk/HxE9EVjHbtO/rE7BBlAoGAaHqM\nOw1IZidZErYhvGil+6c1MF8BVJAp0s6Jw1p+IU7S8bUS0ebuo0cetZFyc5R4AinT\nEuVp8+J6QdWKqu2Ol76Z0kvnV45C9nbIPCRGhas/3luFCYK5Q3MHhzk8BxghlBzN\nNUYLqWox0vMp5KrplaA0VJat1JDmWW3OOOVkNNECgYATeWPgv9rV0fG4pk1tE3sC\ntEzNe3t5Kb8HvIWARY8wPPV/rzXSCk3MmQ7WnCS0GSlZgHv0YFSd59zfwMOFuP+L\nkBDHjOXhiGqnlIljT6MNEudJDEwQM91Ftr8m9shZkcT6C0Rs2hzQ7YVAwqYvyL33\nMhr0XtJAbAWhGGHlWE2SIA==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@smart-donation-adff1.iam.gserviceaccount.com",
"client_id": "113713724581795046586",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40smart-donation-adff1.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

38
web/firebase-config.js Normal file
View File

@ -0,0 +1,38 @@
// firebase-config.js — Shared across all pages
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js";
import { getDatabase } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
const firebaseConfig = {
apiKey: "AIzaSyAYi-fkx2aaQUMhpMMMQA8KH92-pBmqurU",
authDomain: "smart-donation-adff1.firebaseapp.com",
databaseURL: "https://smart-donation-adff1-default-rtdb.asia-southeast1.firebasedatabase.app",
projectId: "smart-donation-adff1",
storageBucket: "smart-donation-adff1.firebasestorage.app",
messagingSenderId: "252890778703",
appId: "1:252890778703:web:ef5bf06d2831cdea9d2e3a"
};
const app = initializeApp(firebaseConfig);
export const db = getDatabase(app);
// ── Helpers ──────────────────────────────────────────────
export const formatRp = (n) => "Rp " + (n || 0).toLocaleString('id-ID');
export const formatWaktu = (ts) => {
const d = new Date(ts);
return [d.getHours(), d.getMinutes(), d.getSeconds()]
.map(v => String(v).padStart(2, '0')).join(':');
};
export const formatTanggal = (ts) => {
const d = new Date(ts);
const months = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Ags','Sep','Okt','Nov','Des'];
return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
};
export const isToday = (ts) => {
const d = new Date(ts), t = new Date();
return d.getDate() === t.getDate() &&
d.getMonth() === t.getMonth() &&
d.getFullYear() === t.getFullYear();
};

263
web/index.html Normal file
View File

@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — Smart Donation Box</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="style.css">
<style>
.foto-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 24px;
}
.foto-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
transition: background-color 0.3s ease;
}
.foto-card-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.foto-card-header h3 {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.pulse-dot {
width: 8px;
height: 8px;
background: var(--primary-green);
border-radius: 50%;
animation: pulse 2s infinite;
display: inline-block;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(1.3); }
}
.foto-container {
position: relative;
width: 100%;
aspect-ratio: 4/3;
background: #0a0f1a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.foto-container img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
transition: opacity 0.4s ease;
}
.foto-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-muted);
}
.foto-placeholder i { font-size: 40px; opacity: 0.3; }
.foto-placeholder p { font-size: 13px; }
.foto-footer {
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.foto-label {
font-size: 13px;
font-weight: 600;
color: var(--primary-green);
background: var(--primary-green-light);
padding: 4px 10px;
border-radius: 6px;
}
.foto-time {
font-size: 12px;
color: var(--text-muted);
}
.summary-grid-dash {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 0;
}
</style>
</head>
<body>
<script type="module">
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = 'login.html';
}
window.logout = function() {
if (confirm('Apakah Anda yakin ingin keluar?')) {
localStorage.removeItem('isLoggedIn');
window.location.href = 'login.html';
}
}
import { initLayout } from './layout.js';
import { db, formatRp, formatWaktu, isToday } from './firebase-config.js';
import { ref, onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
initLayout('dashboard');
document.getElementById('content-area').innerHTML = `
<div class="summary-grid-dash">
<div class="card stat-card">
<div class="stat-icon bg-green-light"><i class="fa-regular fa-calendar-check"></i></div>
<div class="stat-info">
<p>Donasi Hari Ini</p>
<h2 id="dash-donasi-hari-ini">Rp 0</h2>
</div>
</div>
<div class="card stat-card">
<div class="stat-icon bg-blue-light"><i class="fa-solid fa-wallet"></i></div>
<div class="stat-info">
<p>Total Donasi Terkumpul</p>
<h2 id="dash-total-donasi">Rp 0</h2>
</div>
</div>
</div>
<div class="foto-section">
<div class="foto-card">
<div class="foto-card-header">
<h3>
<span class="pulse-dot"></span>
Foto Deteksi Terakhir
</h3>
<span id="foto-badge-status" style="font-size:12px; color:var(--text-muted);">Menunggu...</span>
</div>
<div class="foto-container">
<div class="foto-placeholder" id="foto-placeholder">
<i class="fa-solid fa-camera"></i>
<p>Belum ada deteksi</p>
</div>
<img id="foto-deteksi" src="" alt="Foto Deteksi"
style="display:none;"
onerror="this.style.display='none'; document.getElementById('foto-placeholder').style.display='flex';">
</div>
<div class="foto-footer">
<span class="foto-label" id="foto-label-hasil"></span>
<span class="foto-time" id="foto-waktu"></span>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Transaksi Terakhir</h3>
<a href="log.html" style="font-size:13px; color:var(--primary-green); text-decoration:none; font-weight:500;">
Lihat Semua
</a>
</div>
<table class="styled-table" id="table-transaksi-terakhir">
<tr><td colspan="2" style="text-align:center; color:var(--text-muted); padding:32px;">
<i class="fa-solid fa-spinner fa-spin"></i> Memuat...
</td></tr>
</table>
</div>
</div>
`;
onValue(ref(db, 'smart_donation_box/summary'), (snap) => {
const d = snap.val() || {};
document.getElementById('dash-total-donasi').innerText = formatRp(d.total_donasi);
});
onValue(ref(db, 'smart_donation_box/donasi_log'), (snap) => {
const data = snap.val() || {};
// Filter: buang nominal 0
const keys = Object.keys(data)
.reverse()
.filter(key => (data[key].nominal || 0) > 0);
let donasiHariIni = 0;
let html = '';
if (keys.length === 0) {
html = `<tr><td colspan="2" style="text-align:center; color:var(--text-muted); padding:32px;">
Belum ada transaksi
</td></tr>`;
} else {
keys.forEach((key, i) => {
const item = data[key];
if (isToday(item.timestamp)) donasiHariIni += item.nominal;
if (i < 5) {
html += `
<tr>
<td>
<div style="display:flex; align-items:center; gap:12px;">
<div style="background:var(--primary-green-light);
color:var(--primary-green);
width:32px; height:32px; border-radius:50%;
display:flex; align-items:center; justify-content:center;
flex-shrink:0;">
<i class="fa-solid fa-arrow-down" style="font-size:12px;"></i>
</div>
<div>
<div style="font-weight:600; font-size:13px;">Donasi Masuk</div>
<div style="font-size:11px; color:var(--text-muted);">${formatWaktu(item.timestamp)}</div>
</div>
</div>
</td>
<td style="font-weight:600; text-align:right;">${formatRp(item.nominal)}</td>
</tr>
`;
}
});
}
document.getElementById('dash-donasi-hari-ini').innerText = formatRp(donasiHariIni);
document.getElementById('table-transaksi-terakhir').innerHTML = html;
});
onValue(ref(db, 'smart_donation_box/last_detection'), (snap) => {
const d = snap.val();
if (!d || !d.foto_url) return;
const img = document.getElementById('foto-deteksi');
const placeholder = document.getElementById('foto-placeholder');
const badge = document.getElementById('foto-badge-status');
const label = document.getElementById('foto-label-hasil');
const waktu = document.getElementById('foto-waktu');
badge.textContent = 'Update otomatis';
badge.style.color = 'var(--primary-green)';
label.textContent = d.label || '—';
waktu.textContent = formatWaktu(d.timestamp);
const isBase64 = d.foto_url.startsWith('data:image');
img.onload = () => {
img.style.display = 'block';
placeholder.style.display = 'none';
};
img.onerror = () => {
img.style.display = 'none';
placeholder.style.display = 'flex';
};
img.src = isBase64 ? d.foto_url : d.foto_url + '?t=' + d.timestamp;
});
</script>
</body>
</html>

239
web/laporan.html Normal file
View File

@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laporan — Smart Donation Box</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="style.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<style>
.btn-lihat-semua {
display: flex;
width: 100%;
padding: 12px;
margin-top: 4px;
background: none;
border: 1px dashed var(--border-color);
border-radius: 0 0 12px 12px;
color: var(--primary-green);
font-size: 13px;
font-weight: 600;
font-family: 'Poppins', sans-serif;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-lihat-semua:hover {
background: var(--primary-green-light);
border-color: var(--primary-green);
}
tr.hidden-row { display: none; }
tr.hidden-row.visible { display: table-row; }
</style>
</head>
<body>
<script type="module">
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = 'login.html';
}
import { initLayout } from './layout.js';
import { db, formatRp, formatWaktu, formatTanggal } from './firebase-config.js';
import { ref, onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
initLayout('laporan');
document.getElementById('content-area').innerHTML = `
<!-- SUMMARY -->
<div class="summary-grid" style="margin-bottom:24px;">
<div class="card">
<p style="color:var(--text-muted); font-size:13px;">
<i class="fa-solid fa-arrow-trend-up text-green"></i> TOTAL PEMASUKAN
</p>
<h2 id="lap-pemasukan" style="margin-top:6px;">Rp 0</h2>
</div>
<div class="card">
<p style="color:var(--text-muted); font-size:13px;">
<i class="fa-solid fa-arrow-trend-down text-red"></i> TOTAL PENGELUARAN
</p>
<h2 id="lap-pengeluaran" style="margin-top:6px;">Rp 0</h2>
</div>
<div class="card" style="background:var(--primary-green); color:white; border:none;">
<p style="font-size:13px; opacity:0.85;">
<i class="fa-solid fa-building-columns"></i> SALDO AKHIR
</p>
<h2 id="lap-saldo" style="margin-top:6px;">Rp 0</h2>
</div>
</div>
<!-- EXPORT PDF -->
<div class="card" style="text-align:center; padding:60px 20px;
border: 2px dashed var(--primary-green-light); margin-bottom:24px;">
<i class="fa-solid fa-file-pdf" style="font-size:48px; color:var(--primary-green); margin-bottom:16px;"></i>
<h3 style="margin-bottom:8px;">Siap untuk diaudit?</h3>
<p style="color:var(--text-muted); font-size:14px; max-width:400px; margin:0 auto 24px;">
Unduh salinan resmi laporan dalam format PDF untuk keperluan arsip fisik atau papan pengumuman masjid.
</p>
<button class="btn-primary" id="btn-export-pdf" style="width:auto; padding:12px 32px;">
<i class="fa-solid fa-download"></i> Export PDF
</button>
</div>
<!-- TABEL RINGKASAN DONASI -->
<div class="card" style="margin-bottom:24px;">
<div class="card-header">
<h3><i class="fa-solid fa-list-check" style="color:var(--primary-green);"></i> Ringkasan Donasi</h3>
</div>
<table class="styled-table">
<thead>
<tr>
<th>Waktu</th>
<th>Nominal</th>
<th>Confidence</th>
<th>Status</th>
</tr>
</thead>
<tbody id="lap-table-donasi">
<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:24px;">
<i class="fa-solid fa-spinner fa-spin"></i> Memuat...
</td></tr>
</tbody>
</table>
<button class="btn-lihat-semua" id="btn-expand-lap" style="display:none;">
<i class="fa-solid fa-chevron-down" id="lap-expand-icon"></i>
<span id="lap-expand-text">Lihat Selengkapnya</span>
</button>
</div>
`;
let snapshotDonasi = {};
let snapshotPengeluaran = {};
let snapshotSummary = {};
let isExpanded = false;
// ── Firebase: Summary ──
onValue(ref(db, 'smart_donation_box/summary'), (snap) => {
snapshotSummary = snap.val() || {};
const saldo = (snapshotSummary.total_donasi || 0) - (snapshotSummary.total_pengeluaran || 0);
document.getElementById('lap-pemasukan').innerText = formatRp(snapshotSummary.total_donasi);
document.getElementById('lap-pengeluaran').innerText = formatRp(snapshotSummary.total_pengeluaran);
document.getElementById('lap-saldo').innerText = formatRp(saldo);
});
// ── Firebase: Donasi Log ──
onValue(ref(db, 'smart_donation_box/donasi_log'), (snap) => {
snapshotDonasi = snap.val() || {};
// Filter: buang entry dengan nominal 0
const keys = Object.keys(snapshotDonasi)
.reverse()
.filter(key => (snapshotDonasi[key].nominal || 0) > 0);
let html = '';
if (keys.length === 0) {
html = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:24px;">Belum ada data</td></tr>`;
document.getElementById('btn-expand-lap').style.display = 'none';
} else {
keys.forEach((key, i) => {
const item = snapshotDonasi[key];
const conf = ((item.confidence || 0) * 100).toFixed(0);
const hiddenClass = i >= 5 ? 'hidden-row' : '';
html += `
<tr class="${hiddenClass}">
<td style="color:var(--text-muted);">${formatWaktu(item.timestamp)}</td>
<td style="font-weight:600;">${formatRp(item.nominal)}</td>
<td><span style="font-weight:600;">${conf}%</span></td>
<td><span class="badge-valid">Valid</span></td>
</tr>
`;
});
const btnExpand = document.getElementById('btn-expand-lap');
btnExpand.style.display = keys.length > 5 ? 'flex' : 'none';
}
document.getElementById('lap-table-donasi').innerHTML = html;
attachExpandHandler();
});
// ── Firebase: Pengeluaran Log ──
onValue(ref(db, 'smart_donation_box/pengeluaran_log'), (snap) => {
snapshotPengeluaran = snap.val() || {};
});
function attachExpandHandler() {
const btn = document.getElementById('btn-expand-lap');
if (!btn) return;
btn.onclick = () => {
isExpanded = !isExpanded;
document.querySelectorAll('#lap-table-donasi tr.hidden-row').forEach(row => {
row.classList.toggle('visible', isExpanded);
});
document.getElementById('lap-expand-icon').className =
isExpanded ? 'fa-solid fa-chevron-up' : 'fa-solid fa-chevron-down';
document.getElementById('lap-expand-text').textContent =
isExpanded ? 'Sembunyikan' : 'Lihat Selengkapnya';
};
}
// ── Export PDF ──
document.getElementById('btn-export-pdf').addEventListener('click', () => {
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
doc.setFontSize(18);
doc.setTextColor(16, 185, 129);
doc.text('LAPORAN KAS SMART DONATION BOX', 14, 20);
doc.setFontSize(10);
doc.setTextColor(100, 116, 139);
doc.text(`Dicetak: ${new Date().toLocaleDateString('id-ID', {
weekday:'long', year:'numeric', month:'long', day:'numeric'
})}`, 14, 28);
doc.setFontSize(12);
doc.setTextColor(30, 41, 59);
doc.text('RINGKASAN KEUANGAN', 14, 42);
const saldo = (snapshotSummary.total_donasi || 0) - (snapshotSummary.total_pengeluaran || 0);
doc.autoTable({
startY: 46,
head: [['Keterangan', 'Jumlah']],
body: [
['Total Pemasukan', formatRp(snapshotSummary.total_donasi)],
['Total Pengeluaran', formatRp(snapshotSummary.total_pengeluaran)],
['Saldo Akhir', formatRp(saldo)],
],
headStyles: { fillColor: [16, 185, 129] },
columnStyles: { 1: { halign: 'right' } },
margin: { left: 14, right: 14 }
});
const kelKeys = Object.keys(snapshotPengeluaran).reverse();
if (kelKeys.length > 0) {
doc.text('LOG PENGELUARAN', 14, doc.lastAutoTable.finalY + 14);
doc.autoTable({
startY: doc.lastAutoTable.finalY + 18,
head: [['Tanggal', 'Keterangan', 'Nominal']],
body: kelKeys.map(k => {
const item = snapshotPengeluaran[k];
return [formatTanggal(item.timestamp), item.keterangan, formatRp(item.nominal)];
}),
headStyles: { fillColor: [239, 68, 68] },
margin: { left: 14, right: 14 }
});
}
doc.save(`Laporan_SmartDonationBox_${new Date().toISOString().slice(0,10)}.pdf`);
});
</script>
</body>
</html>

382
web/layout.js Normal file
View File

@ -0,0 +1,382 @@
// layout.js — Inject sidebar + header into every page
// Usage: call initLayout('page-id') at the top of each page script
export function initLayout(activePageId) {
const pageNames = {
'dashboard': { title: "Dashboard", sub: "Assalamu'alaikum, Admin Masjid" },
'log': { title: "Log Transaksi Donasi", sub: "Log Donasi" },
'pengeluaran': { title: "Pengeluaran Dana", sub: "Pengeluaran" },
'laporan': { title: "Laporan Kas", sub: "Laporan" },
};
const navLinks = [
{ id: 'dashboard', href: 'index.html', icon: 'fa-border-all', label: 'Dashboard' },
{ id: 'log', href: 'log.html', icon: 'fa-clock-rotate-left', label: 'Log Donasi' },
{ id: 'pengeluaran', href: 'pengeluaran.html', icon: 'fa-money-bill-transfer', label: 'Pengeluaran' },
{ id: 'laporan', href: 'laporan.html', icon: 'fa-file-lines', label: 'Laporan' },
];
const navHTML = navLinks.map(n => `
<li>
<a href="${n.href}" class="nav-item ${n.id === activePageId ? 'active' : ''}">
<i class="fa-solid ${n.icon}"></i> ${n.label}
</a>
</li>
`).join('');
const info = pageNames[activePageId];
document.body.insertAdjacentHTML('afterbegin', `
<div class="sidebar">
<div class="sidebar-logo">
<i class="fa-solid fa-mosque fa-2x"></i>
<h2>Smart Donation<br>Box</h2>
</div>
<nav class="sidebar-nav">
<ul>${navHTML}</ul>
</nav>
</div>
<main class="main-content">
<header class="header">
<div class="header-title">
<h1>${info.title}</h1>
<p>${info.sub}</p>
</div>
<div class="header-right">
<button id="theme-toggle" class="btn-theme">
<i class="fas fa-moon"></i>
</button>
<!-- Profile button with dropdown -->
<div class="profile-wrapper" id="profile-wrapper">
<div class="profile" id="profile-btn" style="cursor:pointer;" title="Klik untuk opsi akun">
<img src="https://ui-avatars.com/api/?name=Admin+Masjid&background=E2E8F0&color=10B981" alt="Profile">
<span>Admin Masjid</span>
<i class="fa-solid fa-chevron-down" id="chevron-icon" style="font-size:11px; color:var(--text-muted); transition:transform 0.3s;"></i>
</div>
<!-- Dropdown Menu -->
<div class="profile-dropdown" id="profile-dropdown">
<div class="dropdown-header">
<img src="https://ui-avatars.com/api/?name=Admin+Masjid&background=D1FAE5&color=10B981&size=48" alt="Profile">
<div>
<div style="font-weight:600; font-size:14px;">Admin Masjid</div>
<div style="font-size:12px; color:var(--text-muted);">Administrator</div>
</div>
</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-logout" id="btn-logout">
<i class="fa-solid fa-right-from-bracket"></i>
Keluar
</button>
</div>
</div>
</div>
</header>
<div class="content-area" id="content-area"></div>
</main>
<!-- Logout Confirmation Modal -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal-box" id="modal-box">
<div class="modal-icon">
<i class="fa-solid fa-right-from-bracket"></i>
</div>
<h3 class="modal-title">Konfirmasi Keluar</h3>
<p class="modal-desc">Apakah Anda yakin ingin keluar dari sistem monitoring?</p>
<div class="modal-actions">
<button class="modal-btn-cancel" id="modal-cancel">
<i class="fa-solid fa-xmark"></i> Tidak, Tetap Di Sini
</button>
<button class="modal-btn-logout" id="modal-confirm">
<i class="fa-solid fa-right-from-bracket"></i> Ya, Keluar
</button>
</div>
</div>
</div>
`);
/* ── Inject dropdown + modal styles ── */
const style = document.createElement('style');
style.textContent = `
/* Profile Wrapper */
.profile-wrapper {
position: relative;
}
.profile {
display: flex;
align-items: center;
gap: 10px;
background: var(--card-bg);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease, border-color 0.2s;
user-select: none;
}
.profile:hover {
border-color: var(--primary-green);
background: var(--primary-green-light);
}
.profile img { width: 32px; height: 32px; border-radius: 50%; }
.profile span { font-size: 14px; font-weight: 500; }
/* Dropdown */
.profile-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
min-width: 220px;
z-index: 999;
overflow: hidden;
opacity: 0;
transform: translateY(-8px) scale(0.97);
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.profile-dropdown.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
}
.dropdown-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.dropdown-header img {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid var(--primary-green-light);
}
.dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 0 12px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 13px 16px;
background: none;
border: none;
font-family: 'Poppins', sans-serif;
font-size: 14px;
font-weight: 500;
color: var(--text-main);
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.dropdown-item:hover {
background: var(--bg-color);
}
.dropdown-logout {
color: var(--danger-red) !important;
margin: 6px;
border-radius: 8px;
}
.dropdown-logout:hover {
background: rgba(239,68,68,0.08) !important;
}
/* ── Modal Overlay ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.modal-overlay.show {
opacity: 1;
pointer-events: all;
}
.modal-box {
background: var(--card-bg);
border-radius: 20px;
padding: 36px 32px 28px;
max-width: 380px;
width: 90%;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
transform: scale(0.88) translateY(20px);
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease;
opacity: 0;
}
.modal-overlay.show .modal-box {
transform: scale(1) translateY(0);
opacity: 1;
}
.modal-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(239,68,68,0.1);
color: var(--danger-red);
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 10px;
color: var(--text-main);
}
.modal-desc {
font-size: 14px;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 28px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.modal-btn-cancel {
flex: 1;
padding: 11px 16px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-color);
color: var(--text-main);
font-family: 'Poppins', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.2s;
}
.modal-btn-cancel:hover {
background: var(--border-color);
}
.modal-btn-logout {
flex: 1;
padding: 11px 16px;
border: none;
border-radius: 10px;
background: var(--danger-red);
color: #fff;
font-family: 'Poppins', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.2s;
}
.modal-btn-logout:hover {
background: #DC2626;
}
`;
document.head.appendChild(style);
/* ── Dark mode logic ── */
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = themeToggle.querySelector('i');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
themeIcon.classList.replace('fa-moon', 'fa-sun');
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
const isDark = document.body.classList.contains('dark-mode');
themeIcon.classList.replace(isDark ? 'fa-moon' : 'fa-sun', isDark ? 'fa-sun' : 'fa-moon');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
/* ── Profile Dropdown toggle ── */
const profileBtn = document.getElementById('profile-btn');
const dropdown = document.getElementById('profile-dropdown');
const chevron = document.getElementById('chevron-icon');
profileBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
chevron.style.transform = isOpen ? 'rotate(180deg)' : 'rotate(0deg)';
});
// Close dropdown when clicking outside
document.addEventListener('click', () => {
dropdown.classList.remove('open');
chevron.style.transform = 'rotate(0deg)';
});
/* ── Logout button in dropdown → show modal ── */
const btnLogout = document.getElementById('btn-logout');
const overlay = document.getElementById('modal-overlay');
const modalCancel = document.getElementById('modal-cancel');
const modalConfirm= document.getElementById('modal-confirm');
function showModal() {
dropdown.classList.remove('open');
chevron.style.transform = 'rotate(0deg)';
overlay.classList.add('show');
}
function hideModal() {
overlay.classList.remove('show');
}
function doLogout() {
localStorage.removeItem('isLoggedIn');
window.location.href = 'login.html';
}
btnLogout.addEventListener('click', showModal);
modalCancel.addEventListener('click', hideModal);
modalConfirm.addEventListener('click', doLogout);
// Close modal on overlay background click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) hideModal();
});
// Expose global logout for backward compat (index.html calls window.logout)
window.logout = showModal;
}

176
web/log.html Normal file
View File

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Donasi — Smart Donation Box</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="style.css">
<style>
/* Tombol lihat selengkapnya */
.btn-lihat-semua {
display: block;
width: 100%;
padding: 12px;
margin-top: 4px;
background: none;
border: 1px dashed var(--border-color);
border-radius: 0 0 12px 12px;
color: var(--primary-green);
font-size: 13px;
font-weight: 600;
font-family: 'Poppins', sans-serif;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-lihat-semua:hover {
background: var(--primary-green-light);
border-color: var(--primary-green);
}
.btn-lihat-semua.expanded {
border-radius: 8px;
margin-top: 8px;
}
tr.hidden-row { display: none; }
tr.hidden-row.visible { display: table-row; }
</style>
</head>
<body>
<script type="module">
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = 'login.html';
}
import { initLayout } from './layout.js';
import { db, formatRp, formatWaktu, isToday } from './firebase-config.js';
import { ref, onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
initLayout('log');
document.getElementById('content-area').innerHTML = `
<div class="card stat-card" style="margin-bottom:24px; max-width:400px;">
<div class="stat-icon bg-green-light"><i class="fa-regular fa-calendar-check"></i></div>
<div class="stat-info">
<p>Donasi Hari Ini</p>
<h2 id="log-donasi-hari-ini">Rp 0</h2>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fa-solid fa-clock-rotate-left" style="color:var(--primary-green);"></i> Riwayat Deteksi</h3>
</div>
<table class="styled-table">
<thead>
<tr>
<th>Waktu</th>
<th>Nominal Donasi</th>
<th>Confidence Deteksi</th>
<th>Status</th>
</tr>
</thead>
<tbody id="table-log-donasi">
<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:32px;">
<i class="fa-solid fa-spinner fa-spin"></i> Memuat data...
</td></tr>
</tbody>
</table>
<button class="btn-lihat-semua" id="btn-expand-log" style="display:none;">
<i class="fa-solid fa-chevron-down" id="expand-icon"></i>
<span id="expand-text">Lihat Selengkapnya</span>
</button>
</div>
`;
let isExpanded = false;
// ── Firebase: Donasi Log ──
onValue(ref(db, 'smart_donation_box/donasi_log'), (snap) => {
const data = snap.val() || {};
// Filter: buang entry dengan nominal 0
const keys = Object.keys(data)
.reverse()
.filter(key => (data[key].nominal || 0) > 0);
let donasiHariIni = 0;
let html = '';
if (keys.length === 0) {
html = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:32px;">
Belum ada data donasi
</td></tr>`;
document.getElementById('btn-expand-log').style.display = 'none';
} else {
keys.forEach((key, i) => {
const item = data[key];
if (isToday(item.timestamp)) donasiHariIni += item.nominal;
const confPercent = ((item.confidence || 0) * 100).toFixed(0);
const barColor = confPercent > 80
? 'var(--primary-green)'
: confPercent > 50 ? '#F59E0B' : 'var(--danger-red)';
// Baris ke-6 dst disembunyikan awal
const hiddenClass = i >= 5 ? 'hidden-row' : '';
html += `
<tr class="${hiddenClass}">
<td style="color:var(--text-muted);">${formatWaktu(item.timestamp)}</td>
<td style="font-weight:600;">${formatRp(item.nominal)}</td>
<td>
<div class="confidence-bar">
<div class="progress-track">
<div class="progress-fill" style="width:${confPercent}%; background:${barColor};"></div>
</div>
<span style="font-size:12px; font-weight:600;">${confPercent}%</span>
</div>
</td>
<td><span class="badge-valid">Valid</span></td>
</tr>
`;
});
// Tampilkan tombol hanya jika lebih dari 5 baris
const btnExpand = document.getElementById('btn-expand-log');
if (keys.length > 5) {
btnExpand.style.display = 'flex';
} else {
btnExpand.style.display = 'none';
}
}
document.getElementById('log-donasi-hari-ini').innerText = formatRp(donasiHariIni);
document.getElementById('table-log-donasi').innerHTML = html;
// Re-attach expand handler setelah render
attachExpandHandler();
});
function attachExpandHandler() {
const btn = document.getElementById('btn-expand-log');
if (!btn) return;
btn.onclick = () => {
isExpanded = !isExpanded;
const hiddenRows = document.querySelectorAll('#table-log-donasi tr.hidden-row');
const icon = document.getElementById('expand-icon');
const text = document.getElementById('expand-text');
hiddenRows.forEach(row => {
row.classList.toggle('visible', isExpanded);
});
icon.className = isExpanded ? 'fa-solid fa-chevron-up' : 'fa-solid fa-chevron-down';
text.textContent = isExpanded ? 'Sembunyikan' : 'Lihat Selengkapnya';
btn.classList.toggle('expanded', isExpanded);
};
}
</script>
</body>
</html>

294
web/login.html Normal file
View File

@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Smart Donation Box</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>
:root {
--primary-green: hsl(160, 84%, 39%);
--primary-green-light: #D1FAE5;
--card-bg: #FFFFFF;
--text-main: #1E293B;
--text-muted: #64748B;
--border-color: #E2E8F0;
--danger-red: #EF4444;
--bg-color: #F0F4F0;
--input-bg: #F8F9FB;
--nav-bg: #FFFFFF;
}
/* ── Dark mode variables ── */
body.dark-mode {
--bg-color: #000000;
--card-bg: #0a111c;
--text-main: #F8FAFC;
--text-muted: #94A3B8;
--border-color: #334155;
--primary-green-light: rgba(16, 185, 129, 0.2);
--input-bg: #111827;
--nav-bg: #0a111c;
}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Poppins', sans-serif; }
body {
background: var(--bg-color);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color 0.3s ease, color 0.3s ease;
color: var(--text-main);
}
/* ── Top Nav ── */
.top-nav {
background: var(--nav-bg);
border-bottom: 1px solid var(--border-color);
padding: 14px 32px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.3s ease;
}
.top-nav-logo { display: flex; align-items: center; gap: 10px; color: var(--primary-green); }
.top-nav-logo i { font-size: 22px; }
.top-nav-logo span { font-size: 15px; font-weight: 600; color: var(--text-main); }
/* Theme toggle di nav */
.btn-theme-nav {
background: none;
border: 1px solid var(--border-color);
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
color: var(--text-main);
font-size: 14px;
transition: all 0.3s ease;
}
.btn-theme-nav:hover { background: var(--primary-green-light); color: var(--primary-green); border-color: var(--primary-green); }
body.dark-mode .btn-theme-nav { color: #FFD700; }
/* ── Login wrapper ── */
.login-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
.login-card {
background: var(--card-bg);
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
width: 100%;
max-width: 420px;
padding: 40px 36px 32px;
animation: slideUp 0.4s ease;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
body.dark-mode .login-card { box-shadow: 0 4px 32px rgba(0,0,0,0.4); }
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-icon-wrap { display: flex; align-items: center; justify-content: center; margin-bottom: 20px; }
.icon-circle {
width: 68px; height: 68px; border-radius: 50%;
background: var(--primary-green-light);
display: flex; align-items: center; justify-content: center;
color: var(--primary-green); font-size: 28px;
transition: background-color 0.3s ease;
}
.login-title { text-align: center; margin-bottom: 28px; }
.login-title h2 { font-size: 20px; font-weight: 700; color: var(--text-main); line-height: 1.3; margin-bottom: 6px; transition: color 0.3s; }
.login-title p { font-size: 13px; color: var(--text-muted); }
/* ── Form ── */
.form-group { margin-bottom: 18px; }
.form-group label { display: block; font-size: 13px; font-weight: 500; color: var(--text-muted); margin-bottom: 8px; }
.input-wrapper { position: relative; }
.input-wrapper .icon-left { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: #94A3B8; font-size: 14px; }
.input-wrapper input {
width: 100%;
padding: 12px 44px 12px 40px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
font-family: 'Poppins', sans-serif;
color: var(--text-main);
background: var(--input-bg);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s;
}
.input-wrapper input:focus { border-color: var(--primary-green); box-shadow: 0 0 0 3px rgba(16,185,129,0.12); }
.input-wrapper input::placeholder { color: #CBD5E1; }
body.dark-mode .input-wrapper input::placeholder { color: #475569; }
/* Toggle mata */
.btn-toggle-pass {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer;
color: #94A3B8; font-size: 15px; padding: 4px;
display: flex; align-items: center;
transition: color 0.2s;
}
.btn-toggle-pass:hover { color: var(--primary-green); }
.btn-login {
width: 100%; padding: 13px;
background: var(--primary-green); color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600;
font-family: 'Poppins', sans-serif; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 8px; transition: background 0.2s, transform 0.1s;
}
.btn-login:hover { background: #059669; }
.btn-login:active { transform: scale(0.98); }
.btn-login:disabled { opacity: 0.7; cursor: not-allowed; }
.msg-error { text-align: center; margin-top: 14px; font-size: 13px; min-height: 18px; color: var(--danger-red); }
.login-note {
text-align: center; margin-top: 24px; padding-top: 20px;
border-top: 1px solid var(--border-color);
font-size: 12px; color: var(--text-muted); line-height: 1.7;
transition: border-color 0.3s;
}
.login-footer { text-align: center; padding: 20px; font-size: 12px; color: var(--text-muted); }
.login-footer .footer-brand { color: var(--primary-green); display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 4px; }
</style>
</head>
<body>
<nav class="top-nav">
<div class="top-nav-logo">
<i class="fa-solid fa-mosque"></i>
<span>Smart Donation Box</span>
</div>
<!-- Tombol dark mode sama seperti di dalam dashboard -->
<button class="btn-theme-nav" id="btn-theme-login" title="Toggle dark mode">
<i class="fas fa-moon" id="login-theme-icon"></i>
</button>
</nav>
<div class="login-wrapper">
<div class="login-card">
<div class="login-icon-wrap">
<div class="icon-circle"><i class="fa-solid fa-mosque"></i></div>
</div>
<div class="login-title">
<h2>Smart Donation Box<br>Monitoring System</h2>
<p>Monitoring Donasi Kotak Amal Berbasis IoT</p>
</div>
<div class="form-group">
<label>Username</label>
<div class="input-wrapper">
<i class="fa-regular fa-user icon-left"></i>
<input type="text" id="user" placeholder="Enter your username" autocomplete="username">
</div>
</div>
<div class="form-group">
<label>Password</label>
<div class="input-wrapper">
<i class="fa-solid fa-lock icon-left"></i>
<input type="password" id="pass" placeholder="Enter your password" autocomplete="current-password">
<button class="btn-toggle-pass" id="btn-toggle-pass" type="button" title="Lihat/sembunyikan password">
<i class="fa-regular fa-eye" id="pass-eye-icon"></i>
</button>
</div>
</div>
<button class="btn-login" id="btn-login">
Login <i class="fa-solid fa-arrow-right-to-bracket"></i>
</button>
<p class="msg-error" id="msg"></p>
<div class="login-note">
This project is developed for final year undergraduate requirements.<br>
Ensuring transparency in community donations.
</div>
</div>
</div>
<footer class="login-footer">
<div class="footer-brand">
<i class="fa-solid fa-mosque"></i>
Transparansi Donasi Terjamin dengan Sistem IoT
</div>
© 2026 Proyek Akhir Smart Donation Box
</footer>
<script type="module">
import { db } from './firebase-config.js';
import { ref, get } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
// ── Sinkronisasi dark mode dengan dashboard ──
const themeIcon = document.getElementById('login-theme-icon');
function applyTheme(dark) {
document.body.classList.toggle('dark-mode', dark);
themeIcon.className = dark ? 'fas fa-sun' : 'fas fa-moon';
}
// Load state tersimpan dari localStorage (sama dengan dashboard)
applyTheme(localStorage.getItem('theme') === 'dark');
document.getElementById('btn-theme-login').addEventListener('click', () => {
const isDark = !document.body.classList.contains('dark-mode');
applyTheme(isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
// ── Toggle lihat/sembunyikan password ──
const passInput = document.getElementById('pass');
const eyeIcon = document.getElementById('pass-eye-icon');
document.getElementById('btn-toggle-pass').addEventListener('click', () => {
const isHidden = passInput.type === 'password';
passInput.type = isHidden ? 'text' : 'password';
eyeIcon.className = isHidden ? 'fa-regular fa-eye-slash' : 'fa-regular fa-eye';
});
// Enter key support
passInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btn.click(); });
// ── Login logic ──
const btn = document.getElementById('btn-login');
const msg = document.getElementById('msg');
btn.addEventListener('click', async () => {
const userIn = document.getElementById('user').value.trim();
const passIn = passInput.value;
msg.innerText = '';
if (!userIn || !passIn) { msg.innerText = '⚠️ Username dan Password wajib diisi!'; return; }
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Masuk...';
try {
const snap = await get(ref(db, 'smart_donation_box/users/admin'));
const data = snap.val();
if (data && data.username === userIn && data.password === passIn) {
localStorage.setItem('isLoggedIn', 'true');
window.location.href = 'index.html';
} else {
msg.innerText = '⚠️ Username atau Password salah!';
btn.disabled = false;
btn.innerHTML = 'Login <i class="fa-solid fa-arrow-right-to-bracket"></i>';
}
} catch (err) {
msg.innerText = '❌ Gagal terhubung ke server. Coba lagi.';
btn.disabled = false;
btn.innerHTML = 'Login <i class="fa-solid fa-arrow-right-to-bracket"></i>';
}
});
</script>
</body>
</html>

162
web/pengeluaran.html Normal file
View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pengeluaran — Smart Donation Box</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<script type="module">
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = 'login.html';
}
import { initLayout } from './layout.js';
import { db, formatRp, formatTanggal } from './firebase-config.js';
import { ref, onValue, push, get, update } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
initLayout('pengeluaran');
document.getElementById('content-area').innerHTML = `
<div class="grid-2">
<div class="card">
<div class="card-header">
<h3 style="color:var(--primary-green);"><i class="fa-solid fa-circle-plus"></i> Input Pengeluaran Baru</h3>
</div>
<div class="form-group">
<label>Nominal (Rp)</label>
<input type="number" id="input-nominal-keluar" placeholder="Misal: 50000" min="0">
</div>
<div class="form-group">
<label>Keterangan</label>
<input type="text" id="input-ket-keluar" placeholder="Misal: Pembelian alat kebersihan">
</div>
<button class="btn-primary" id="btn-simpan-pengeluaran">
<i class="fa-solid fa-floppy-disk"></i> Simpan Pengeluaran
</button>
<p id="msg-pengeluaran" style="margin-top:12px; font-size:13px; text-align:center; min-height:20px;"></p>
</div>
<div style="display:flex; flex-direction:column; gap:24px;">
<div class="card" style="flex:1;">
<div class="card-header">
<h3><i class="fa-solid fa-clock-rotate-left" style="color:var(--primary-green);"></i> Riwayat Pengeluaran</h3>
</div>
<table class="styled-table">
<thead>
<tr>
<th>Tanggal</th>
<th>Keterangan</th>
<th>Nominal</th>
</tr>
</thead>
<tbody id="table-riwayat-pengeluaran">
<tr><td colspan="3" style="text-align:center; color:var(--text-muted); padding:24px;">
<i class="fa-solid fa-spinner fa-spin"></i> Memuat...
</td></tr>
</tbody>
</table>
</div>
<div class="summary-grid-2" style="margin-bottom:0;">
<div class="card bg-green-light" style="border:none;">
<p style="font-size:13px; color:var(--primary-green);">Total Pengeluaran</p>
<h2 class="text-green" id="pengeluaran-total" style="margin-top:4px;">Rp 0</h2>
</div>
<div class="card">
<p style="font-size:13px; color:var(--text-muted);">Sisa Saldo Kas</p>
<h2 id="pengeluaran-saldo" style="margin-top:4px;">Rp 0</h2>
</div>
</div>
</div>
</div>
`;
const summaryRef = ref(db, 'smart_donation_box/summary');
onValue(summaryRef, (snap) => {
const d = snap.val() || {};
const saldo = (d.total_donasi || 0) - (d.total_pengeluaran || 0);
document.getElementById('pengeluaran-total').innerText = formatRp(d.total_pengeluaran);
document.getElementById('pengeluaran-saldo').innerText = formatRp(saldo);
});
onValue(ref(db, 'smart_donation_box/pengeluaran_log'), (snap) => {
const data = snap.val() || {};
// Filter: buang entry dengan nominal 0 atau keterangan tidak valid
const keys = Object.keys(data)
.reverse()
.filter(key => (data[key].nominal || 0) > 0);
let html = '';
if (keys.length === 0) {
html = `<tr><td colspan="3" style="text-align:center; color:var(--text-muted); padding:24px;">
Belum ada pengeluaran
</td></tr>`;
} else {
keys.forEach(key => {
const item = data[key];
html += `
<tr>
<td style="color:var(--text-muted);">${formatTanggal(item.timestamp)}</td>
<td style="font-weight:500;">${item.keterangan}</td>
<td style="font-weight:600; color:var(--danger-red);">${formatRp(item.nominal)}</td>
</tr>
`;
});
}
document.getElementById('table-riwayat-pengeluaran').innerHTML = html;
});
document.getElementById('btn-simpan-pengeluaran').addEventListener('click', async () => {
const nominal = Number(document.getElementById('input-nominal-keluar').value);
const keterangan = document.getElementById('input-ket-keluar').value.trim();
const msg = document.getElementById('msg-pengeluaran');
if (!nominal || nominal <= 0) return showMsg(msg, '⚠️ Masukkan nominal yang valid!', 'orange');
if (!keterangan) return showMsg(msg, '⚠️ Keterangan tidak boleh kosong!', 'orange');
const btn = document.getElementById('btn-simpan-pengeluaran');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Menyimpan...';
try {
const snap = await get(summaryRef);
const current = snap.val() || {};
const newTotal = (current.total_pengeluaran || 0) + nominal;
const newSaldo = (current.total_donasi || 0) - newTotal;
await push(ref(db, 'smart_donation_box/pengeluaran_log'), {
nominal, keterangan, timestamp: Date.now()
});
await update(summaryRef, {
total_pengeluaran: newTotal,
saldo: newSaldo
});
document.getElementById('input-nominal-keluar').value = '';
document.getElementById('input-ket-keluar').value = '';
showMsg(msg, '✅ Pengeluaran berhasil disimpan!', 'var(--primary-green)');
} catch (e) {
console.error(e);
showMsg(msg, '❌ Gagal menyimpan. Coba lagi.', 'var(--danger-red)');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Simpan Pengeluaran';
}
});
function showMsg(el, text, color) {
el.innerText = text;
el.style.color = color;
setTimeout(() => el.innerText = '', 3000);
}
</script>
</body>
</html>

166
web/style.css Normal file
View File

@ -0,0 +1,166 @@
/* style.css — Shared styles for all pages */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap');
:root {
--bg-color: #F8F9FB;
--sidebar-bg: #FFFFFF;
--card-bg: #FFFFFF;
--text-main: #1E293B;
--text-muted: #64748B;
--primary-green: hsl(160, 84%, 39%);
--primary-green-light: #D1FAE5;
--border-color: #E2E8F0;
--danger-red: #EF4444;
}
body.dark-mode {
--bg-color: #000000;
--sidebar-bg: #000000;
--card-bg: #0a111c;
--text-main: #F8FAFC;
--text-muted: #94A3B8;
--border-color: #334155;
--primary-green-light: rgba(16, 185, 129, 0.379);
}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Poppins', sans-serif; }
body {
background-color: var(--bg-color);
color: var(--text-main);
display: flex;
height: 100vh;
overflow: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* ── SIDEBAR ── */
.sidebar {
width: 260px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px 0;
transition: background-color 0.3s ease;
flex-shrink: 0;
}
.sidebar-logo {
display: flex; align-items: center; gap: 10px;
padding: 0 24px 30px; color: var(--primary-green);
}
.sidebar-logo h2 { font-size: 18px; font-weight: 700; color: var(--text-main); }
.sidebar-nav ul { list-style: none; }
.nav-item {
padding: 12px 24px; margin: 4px 16px; border-radius: 8px;
cursor: pointer; display: flex; align-items: center; gap: 12px;
color: var(--text-muted); font-weight: 500; transition: 0.3s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-color); color: var(--primary-green); }
.nav-item.active { background: var(--primary-green-light); color: var(--primary-green); font-weight: 600; }
/* ── MAIN CONTENT ── */
.main-content { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 24px 32px; background: var(--bg-color);
transition: background-color 0.3s ease;
border-bottom: 1px solid var(--border-color);
}
.header-title h1 { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.header-title p { font-size: 14px; color: var(--text-muted); }
.header-right { display: flex; align-items: center; gap: 20px; }
.btn-theme {
background: var(--card-bg); border: 1px solid var(--border-color);
width: 40px; height: 40px; border-radius: 50%;
display: flex; justify-content: center; align-items: center;
cursor: pointer; color: var(--text-main); transition: all 0.3s ease;
}
.btn-theme:hover { background: var(--primary-green-light); color: var(--primary-green); border-color: var(--primary-green); }
body.dark-mode .btn-theme { color: #FFD700; }
.profile {
display: flex; align-items: center; gap: 10px;
background: var(--card-bg); padding: 6px 12px;
border-radius: 20px; border: 1px solid var(--border-color);
transition: background-color 0.3s ease;
}
.profile img { width: 32px; height: 32px; border-radius: 50%; }
.profile span { font-size: 14px; font-weight: 500; }
/* ── CONTENT AREA ── */
.content-area { padding: 32px; flex: 1; overflow-y: auto; }
/* ── CARDS ── */
.card {
background: var(--card-bg); padding: 24px; border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 10px rgba(0,0,0,0.02);
transition: background-color 0.3s ease;
}
.card-header {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 20px;
}
.card-header h3 { font-size: 16px; font-weight: 600; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 24px; }
.summary-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.grid-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 24px; }
.stat-card { display: flex; align-items: flex-start; gap: 16px; }
.stat-icon {
width: 48px; height: 48px; border-radius: 12px;
display: flex; align-items: center; justify-content: center; font-size: 20px;
flex-shrink: 0;
}
.bg-green-light { background: var(--primary-green-light); color: var(--primary-green); }
.bg-blue-light { background: rgba(2, 132, 199, 0.1); color: #0284C7; }
.bg-red-light { background: rgba(239, 68, 68, 0.1); color: var(--danger-red); }
.stat-info p { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
.stat-info h2 { font-size: 24px; font-weight: 700; }
/* ── TABLES ── */
.styled-table { width: 100%; border-collapse: collapse; }
.styled-table th {
text-align: left; padding: 12px 16px; font-size: 12px;
color: var(--text-muted); border-bottom: 1px solid var(--border-color);
text-transform: uppercase; letter-spacing: 0.5px;
}
.styled-table td { padding: 16px; border-bottom: 1px solid var(--border-color); font-size: 14px; font-weight: 500; }
.styled-table tr:last-child td { border-bottom: none; }
/* ── PROGRESS & BADGES ── */
.confidence-bar { display: flex; align-items: center; gap: 10px; }
.progress-track { width: 100px; height: 6px; background: var(--border-color); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--primary-green); }
.badge-valid {
background: var(--primary-green-light); color: var(--primary-green);
padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600;
border: 1px solid rgba(16, 185, 129, 0.2);
}
/* ── FORMS ── */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 500; color: var(--text-muted); margin-bottom: 8px; }
.form-group input {
width: 100%; padding: 12px 16px; border: 1px solid var(--border-color);
border-radius: 8px; outline: none; font-size: 14px;
background: var(--bg-color); color: var(--text-main);
transition: border-color 0.2s;
}
.form-group input:focus { border-color: var(--primary-green); }
.btn-primary {
background: var(--primary-green); color: white; border: none;
padding: 12px 24px; border-radius: 8px; font-weight: 600;
cursor: pointer; width: 100%; transition: 0.2s; font-size: 14px;
}
.btn-primary:hover { background: #059669; }
/* ── UTILITIES ── */
.text-green { color: var(--primary-green); }
.text-red { color: var(--danger-red); }
.flex-between { display: flex; justify-content: space-between; align-items: center; }