Upload source code tugas akhir
This commit is contained in:
commit
7d2522b310
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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(¶mIp);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
Loading…
Reference in New Issue