Spaces:
Sleeping
Sleeping
Commit
·
4253e50
0
Parent(s):
The real final commit, clean start
Browse files- .gitattributes +36 -0
- .gitignore +23 -0
- README.md +11 -0
- app.py +253 -0
- assets/clientside.js +105 -0
- assets/default_avatar_dark.jpg +0 -0
- assets/default_avatar_light.jpg +0 -0
- assets/style.css +308 -0
- auth/login.py +103 -0
- auth/signup.py +174 -0
- components/sidebar.py +33 -0
- data/disease_trends.db +3 -0
- database.py +128 -0
- pages/__init__.py +0 -0
- pages/analisis_tren_penyakit.py +450 -0
- pages/beranda.py +137 -0
- pages/distribusi_kasus_demografi.py +263 -0
- pages/input_data.py +253 -0
- pages/laporan.py +365 -0
- pages/logout.py +8 -0
- pages/pengaturan.py +304 -0
- requirements.txt +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.db filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Folder Virtual Environment Python (SANGAT PENTING!)
|
| 2 |
+
# Isinya bisa ratusan MB dan spesifik untuk komputermu saja.
|
| 3 |
+
.venv/
|
| 4 |
+
|
| 5 |
+
# Folder cache Python (Tidak berguna di server)
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
|
| 9 |
+
# File Environment berisi data rahasia (SANGAT PENTING!)
|
| 10 |
+
# JANGAN PERNAH upload file ini. Isinya bisa password database, API key, dll.
|
| 11 |
+
.env
|
| 12 |
+
|
| 13 |
+
# Pengaturan spesifik VS Code (Tidak dibutuhkan di server)
|
| 14 |
+
.vscode/
|
| 15 |
+
|
| 16 |
+
# File sementara dari extension Code Runner
|
| 17 |
+
tempCodeRunnerFile.py
|
| 18 |
+
|
| 19 |
+
# Folder file sementara (jika ada)
|
| 20 |
+
temp_files/
|
| 21 |
+
|
| 22 |
+
# Submodul/folder sisa dari error sebelumnya
|
| 23 |
+
revisi_skripsi/
|
README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Revisi Skripsi
|
| 3 |
+
emoji: 💻
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py (Versi Revisi Siap Deploy)
|
| 2 |
+
|
| 3 |
+
import dash
|
| 4 |
+
import dash_bootstrap_components as dbc
|
| 5 |
+
from dash import Dash, dcc, html, Output, Input, State, no_update
|
| 6 |
+
|
| 7 |
+
# Impor dari proyek Anda
|
| 8 |
+
from pages import (
|
| 9 |
+
beranda,
|
| 10 |
+
analisis_tren_penyakit,
|
| 11 |
+
distribusi_kasus_demografi,
|
| 12 |
+
input_data,
|
| 13 |
+
laporan,
|
| 14 |
+
pengaturan
|
| 15 |
+
)
|
| 16 |
+
from auth import login, signup
|
| 17 |
+
from components import sidebar
|
| 18 |
+
|
| 19 |
+
# -----------------------------------------------------------------------------
|
| 20 |
+
# BAGIAN 1: INISIALISASI APLIKASI (PERUBAHAN UTAMA DI SINI)
|
| 21 |
+
# -----------------------------------------------------------------------------
|
| 22 |
+
# Tidak perlu mengimpor Flask atau menginisialisasi server Flask secara manual lagi.
|
| 23 |
+
# Dash akan melakukannya secara otomatis.
|
| 24 |
+
|
| 25 |
+
# Konfigurasi Tema
|
| 26 |
+
THEME_LIGHT = dbc.themes.FLATLY
|
| 27 |
+
THEME_DARK = dbc.themes.DARKLY
|
| 28 |
+
|
| 29 |
+
# Inisialisasi aplikasi Dash
|
| 30 |
+
app = Dash(
|
| 31 |
+
__name__,
|
| 32 |
+
suppress_callback_exceptions=True,
|
| 33 |
+
external_stylesheets=[THEME_LIGHT, '/assets/style.css'],
|
| 34 |
+
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}]
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Baris ini SANGAT PENTING untuk deployment.
|
| 38 |
+
# Gunicorn akan mencari variabel bernama 'server'.
|
| 39 |
+
server = app.server
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# -----------------------------------------------------------------------------
|
| 43 |
+
# BAGIAN 2: LAYOUT UTAMA APLIKASI (TIDAK ADA PERUBAHAN)
|
| 44 |
+
# -----------------------------------------------------------------------------
|
| 45 |
+
app.layout = html.Div([
|
| 46 |
+
dcc.Location(id='url', refresh=False),
|
| 47 |
+
dcc.Store(id='login-status', storage_type='session'),
|
| 48 |
+
dcc.Store(id='user-theme-preference-store', storage_type='local'),
|
| 49 |
+
dcc.Store(id='previous-url-store', storage_type='session'),
|
| 50 |
+
html.Div(id='app-wrapper'),
|
| 51 |
+
dbc.Modal(
|
| 52 |
+
[
|
| 53 |
+
dbc.ModalHeader(dbc.ModalTitle("Konfirmasi Logout")),
|
| 54 |
+
dbc.ModalBody("Apakah Anda yakin ingin keluar dari sesi ini?"),
|
| 55 |
+
dbc.ModalFooter([
|
| 56 |
+
dbc.Button("Tidak", id="logout-confirm-no", color="secondary", className="ms-auto", n_clicks=0),
|
| 57 |
+
dbc.Button("Ya, Logout", id="logout-confirm-yes", color="danger", className="ms-2", n_clicks=0),
|
| 58 |
+
]),
|
| 59 |
+
],
|
| 60 |
+
id="logout-confirm-modal",
|
| 61 |
+
is_open=False,
|
| 62 |
+
centered=True,
|
| 63 |
+
),
|
| 64 |
+
dcc.Location(id='logout-redirect-location', refresh=True)
|
| 65 |
+
])
|
| 66 |
+
|
| 67 |
+
# -----------------------------------------------------------------------------
|
| 68 |
+
# BAGIAN 3: CALLBACKS (TIDAK ADA PERUBAHAN)
|
| 69 |
+
# -----------------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
# --- Callbacks untuk Tema (JavaScript Inline) ---
|
| 72 |
+
app.clientside_callback(
|
| 73 |
+
"""
|
| 74 |
+
function(pathname, storedThemePreference) {
|
| 75 |
+
let themeToApply = 'LIGHT';
|
| 76 |
+
if (storedThemePreference && typeof storedThemePreference.theme === 'string') {
|
| 77 |
+
themeToApply = storedThemePreference.theme;
|
| 78 |
+
}
|
| 79 |
+
const lightThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/flatly/bootstrap.min.css";
|
| 80 |
+
const darkThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/darkly/bootstrap.min.css";
|
| 81 |
+
let newThemeUrl = lightThemeUrl;
|
| 82 |
+
document.body.classList.remove('theme-dark', 'theme-light');
|
| 83 |
+
if (themeToApply === 'DARK') {
|
| 84 |
+
newThemeUrl = darkThemeUrl;
|
| 85 |
+
document.body.classList.add('theme-dark');
|
| 86 |
+
} else {
|
| 87 |
+
newThemeUrl = lightThemeUrl;
|
| 88 |
+
document.body.classList.add('theme-light');
|
| 89 |
+
}
|
| 90 |
+
let themeLink = document.getElementById('bootstrap-theme');
|
| 91 |
+
if (!themeLink) {
|
| 92 |
+
themeLink = document.createElement('link');
|
| 93 |
+
themeLink.id = 'bootstrap-theme';
|
| 94 |
+
themeLink.rel = 'stylesheet';
|
| 95 |
+
themeLink.type = 'text/css';
|
| 96 |
+
document.getElementsByTagName('head')[0].appendChild(themeLink);
|
| 97 |
+
}
|
| 98 |
+
if (themeLink.href !== newThemeUrl) {
|
| 99 |
+
themeLink.href = newThemeUrl;
|
| 100 |
+
}
|
| 101 |
+
return null;
|
| 102 |
+
}
|
| 103 |
+
""",
|
| 104 |
+
Output('app-wrapper', 'className'),
|
| 105 |
+
Input('url', 'pathname'),
|
| 106 |
+
State('user-theme-preference-store', 'data')
|
| 107 |
+
)
|
| 108 |
+
app.clientside_callback(
|
| 109 |
+
"""
|
| 110 |
+
function(themePreferenceFromStore) {
|
| 111 |
+
let themeToApply = 'LIGHT';
|
| 112 |
+
if (themePreferenceFromStore && typeof themePreferenceFromStore.theme === 'string') {
|
| 113 |
+
themeToApply = themePreferenceFromStore.theme;
|
| 114 |
+
}
|
| 115 |
+
const lightThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/flatly/bootstrap.min.css";
|
| 116 |
+
const darkThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/darkly/bootstrap.min.css";
|
| 117 |
+
let newThemeUrl = lightThemeUrl;
|
| 118 |
+
document.body.classList.remove('theme-dark', 'theme-light');
|
| 119 |
+
if (themeToApply === 'DARK') {
|
| 120 |
+
newThemeUrl = darkThemeUrl;
|
| 121 |
+
document.body.classList.add('theme-dark');
|
| 122 |
+
} else {
|
| 123 |
+
newThemeUrl = lightThemeUrl;
|
| 124 |
+
document.body.classList.add('theme-light');
|
| 125 |
+
}
|
| 126 |
+
let themeLink = document.getElementById('bootstrap-theme');
|
| 127 |
+
if (!themeLink) {
|
| 128 |
+
themeLink = document.createElement('link');
|
| 129 |
+
themeLink.id = 'bootstrap-theme';
|
| 130 |
+
themeLink.rel = 'stylesheet';
|
| 131 |
+
themeLink.type = 'text/css';
|
| 132 |
+
document.getElementsByTagName('head')[0].appendChild(themeLink);
|
| 133 |
+
}
|
| 134 |
+
if (themeLink.href !== newThemeUrl) {
|
| 135 |
+
themeLink.href = newThemeUrl;
|
| 136 |
+
}
|
| 137 |
+
return null;
|
| 138 |
+
}
|
| 139 |
+
""",
|
| 140 |
+
Output('app-wrapper', 'className', allow_duplicate=True),
|
| 141 |
+
Input('user-theme-preference-store', 'data'),
|
| 142 |
+
prevent_initial_call=True
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Callback untuk Navigasi Halaman, Kontrol Sidebar, dan Modal Logout
|
| 146 |
+
@app.callback(
|
| 147 |
+
Output('app-wrapper', 'children'),
|
| 148 |
+
Output('logout-confirm-modal', 'is_open'),
|
| 149 |
+
Input('url', 'pathname'),
|
| 150 |
+
State('login-status', 'data')
|
| 151 |
+
)
|
| 152 |
+
def display_page_logic(pathname, login_data):
|
| 153 |
+
is_logged_in = login_data and login_data.get('logged_in', False)
|
| 154 |
+
no_sidebar_pages = ['/login', '/signup']
|
| 155 |
+
open_logout_modal = False
|
| 156 |
+
|
| 157 |
+
if pathname == '/logout' and is_logged_in:
|
| 158 |
+
open_logout_modal = True
|
| 159 |
+
|
| 160 |
+
# KASUS 1: Pengguna belum login
|
| 161 |
+
if not is_logged_in:
|
| 162 |
+
if pathname in no_sidebar_pages or pathname == '/' or pathname is None:
|
| 163 |
+
if pathname == '/signup':
|
| 164 |
+
return signup.layout, False
|
| 165 |
+
else:
|
| 166 |
+
return login.layout, False
|
| 167 |
+
else:
|
| 168 |
+
return dcc.Location(pathname="/login", id="redirect-to-login-unauth"), False
|
| 169 |
+
|
| 170 |
+
# KASUS 2: Pengguna sudah login
|
| 171 |
+
else:
|
| 172 |
+
if pathname == '/logout':
|
| 173 |
+
page_layout_content = beranda.layout
|
| 174 |
+
elif pathname in no_sidebar_pages or pathname == '/' or pathname is None:
|
| 175 |
+
return dcc.Location(pathname="/beranda", id="redirect-to-home-auth"), False
|
| 176 |
+
elif pathname == '/beranda':
|
| 177 |
+
page_layout_content = beranda.layout
|
| 178 |
+
elif pathname == '/analisis_tren_penyakit':
|
| 179 |
+
page_layout_content = analisis_tren_penyakit.layout
|
| 180 |
+
elif pathname == '/distribusi_kasus_demografi':
|
| 181 |
+
page_layout_content = distribusi_kasus_demografi.layout
|
| 182 |
+
elif pathname == '/input_data':
|
| 183 |
+
page_layout_content = input_data.layout
|
| 184 |
+
elif pathname == '/laporan':
|
| 185 |
+
page_layout_content = laporan.layout
|
| 186 |
+
elif pathname == '/pengaturan':
|
| 187 |
+
page_layout_content = pengaturan.layout
|
| 188 |
+
else:
|
| 189 |
+
page_layout_content = html.H1("404 - Halaman Tidak Ditemukan", className="text-center mt-5")
|
| 190 |
+
|
| 191 |
+
return html.Div([
|
| 192 |
+
sidebar.sidebar_layout,
|
| 193 |
+
html.Div(page_layout_content, id="page-content")
|
| 194 |
+
]), open_logout_modal
|
| 195 |
+
|
| 196 |
+
# Callback untuk menyimpan URL sebelum logout
|
| 197 |
+
@app.callback(
|
| 198 |
+
Output('previous-url-store', 'data'),
|
| 199 |
+
Input('url', 'pathname'),
|
| 200 |
+
State('previous-url-store', 'data')
|
| 201 |
+
)
|
| 202 |
+
def store_previous_url(current_pathname, last_stored_url):
|
| 203 |
+
excluded_paths = ['/logout', '/login', '/signup']
|
| 204 |
+
if current_pathname not in excluded_paths and current_pathname != last_stored_url:
|
| 205 |
+
return current_pathname
|
| 206 |
+
return no_update
|
| 207 |
+
|
| 208 |
+
# Callback untuk Update Profil di Sidebar
|
| 209 |
+
@app.callback(
|
| 210 |
+
Output('sidebar-profile-name', 'children'),
|
| 211 |
+
Output('sidebar-profile-section', 'style'),
|
| 212 |
+
Input('login-status', 'data'),
|
| 213 |
+
Input('url', 'pathname')
|
| 214 |
+
)
|
| 215 |
+
def update_sidebar_profile(login_data, pathname):
|
| 216 |
+
no_sidebar_pages_or_logout = ['/login', '/signup', '/logout']
|
| 217 |
+
if login_data and login_data.get('logged_in'):
|
| 218 |
+
nama_pengguna = login_data.get('nama_lengkap', login_data.get('username', 'Pengguna'))
|
| 219 |
+
if pathname not in no_sidebar_pages_or_logout:
|
| 220 |
+
return nama_pengguna, {'display': 'block'}
|
| 221 |
+
else:
|
| 222 |
+
return no_update, {'display': 'none'}
|
| 223 |
+
return "Nama Pengguna", {'display': 'none'}
|
| 224 |
+
|
| 225 |
+
# Callback untuk Aksi Modal Logout
|
| 226 |
+
@app.callback(
|
| 227 |
+
Output('logout-redirect-location', 'pathname'),
|
| 228 |
+
Output('login-status', 'clear_data', allow_duplicate=True),
|
| 229 |
+
Output('logout-confirm-modal', 'is_open', allow_duplicate=True),
|
| 230 |
+
Input('logout-confirm-yes', 'n_clicks'),
|
| 231 |
+
Input('logout-confirm-no', 'n_clicks'),
|
| 232 |
+
State('previous-url-store', 'data'),
|
| 233 |
+
prevent_initial_call=True
|
| 234 |
+
)
|
| 235 |
+
def handle_logout_confirmation_app(n_yes, n_no, previous_url):
|
| 236 |
+
triggered_id = dash.callback_context.triggered_id if dash.callback_context.triggered_id else None
|
| 237 |
+
|
| 238 |
+
if triggered_id == 'logout-confirm-yes':
|
| 239 |
+
return '/login', True, False
|
| 240 |
+
|
| 241 |
+
elif triggered_id == 'logout-confirm-no':
|
| 242 |
+
url_to_return = previous_url if previous_url else '/beranda'
|
| 243 |
+
return url_to_return, False, False
|
| 244 |
+
|
| 245 |
+
return no_update, no_update, False
|
| 246 |
+
|
| 247 |
+
# -----------------------------------------------------------------------------
|
| 248 |
+
# BAGIAN 4: MENJALANKAN APLIKASI (TIDAK ADA PERUBAHAN)
|
| 249 |
+
# -----------------------------------------------------------------------------
|
| 250 |
+
# Blok ini memastikan server development hanya berjalan saat file ini dieksekusi langsung,
|
| 251 |
+
# dan tidak akan berjalan saat diimpor oleh Gunicorn di server produksi.
|
| 252 |
+
if __name__ == '__main__':
|
| 253 |
+
app.run(debug=True, port=8050)
|
assets/clientside.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// assets/clientside.js
|
| 2 |
+
|
| 3 |
+
console.log(">>> assets/clientside.js (themeManager version) - SCRIPT EXECUTION STARTED <<<");
|
| 4 |
+
|
| 5 |
+
try {
|
| 6 |
+
// Gunakan namespace 'themeManager'
|
| 7 |
+
window.themeManager = {
|
| 8 |
+
// Gunakan nama fungsi 'initPageTheme'
|
| 9 |
+
initPageTheme: function(pathname, storedThemePreference) {
|
| 10 |
+
console.log("JS: themeManager.initPageTheme - Called. Pathname:", pathname, "Stored Pref:", storedThemePreference);
|
| 11 |
+
|
| 12 |
+
let themeToApply = 'LIGHT';
|
| 13 |
+
if (storedThemePreference && typeof storedThemePreference.theme === 'string') {
|
| 14 |
+
themeToApply = storedThemePreference.theme;
|
| 15 |
+
}
|
| 16 |
+
console.log("JS: themeManager.initPageTheme - Theme to apply:", themeToApply);
|
| 17 |
+
|
| 18 |
+
// PASTIKAN URL INI SESUAI DENGAN THEME_LIGHT DAN THEME_DARK DI APP.PY
|
| 19 |
+
const lightThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/flatly/bootstrap.min.css";
|
| 20 |
+
const darkThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/darkly/bootstrap.min.css";
|
| 21 |
+
|
| 22 |
+
let newThemeUrl = lightThemeUrl;
|
| 23 |
+
document.body.classList.remove('theme-dark', 'theme-light');
|
| 24 |
+
|
| 25 |
+
if (themeToApply === 'DARK') {
|
| 26 |
+
newThemeUrl = darkThemeUrl;
|
| 27 |
+
document.body.classList.add('theme-dark');
|
| 28 |
+
} else {
|
| 29 |
+
newThemeUrl = lightThemeUrl;
|
| 30 |
+
document.body.classList.add('theme-light');
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
let themeLink = document.getElementById('bootstrap-theme');
|
| 34 |
+
if (!themeLink) {
|
| 35 |
+
console.log("JS: themeManager.initPageTheme - Creating new theme link element.");
|
| 36 |
+
themeLink = document.createElement('link');
|
| 37 |
+
themeLink.id = 'bootstrap-theme';
|
| 38 |
+
themeLink.rel = 'stylesheet';
|
| 39 |
+
themeLink.type = 'text/css';
|
| 40 |
+
document.getElementsByTagName('head')[0].appendChild(themeLink);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (themeLink.href !== newThemeUrl) {
|
| 44 |
+
console.log("JS: themeManager.initPageTheme - Setting theme link href to:", newThemeUrl);
|
| 45 |
+
themeLink.href = newThemeUrl;
|
| 46 |
+
}
|
| 47 |
+
return window.dash_clientside.no_update;
|
| 48 |
+
},
|
| 49 |
+
|
| 50 |
+
// Gunakan nama fungsi 'applyPageTheme'
|
| 51 |
+
applyPageTheme: function(themePreferenceFromStore) {
|
| 52 |
+
console.log("JS: themeManager.applyPageTheme - Called. New pref from store:", themePreferenceFromStore);
|
| 53 |
+
|
| 54 |
+
let themeToApply = 'LIGHT';
|
| 55 |
+
if (themePreferenceFromStore && typeof themePreferenceFromStore.theme === 'string') {
|
| 56 |
+
themeToApply = themePreferenceFromStore.theme;
|
| 57 |
+
}
|
| 58 |
+
console.log("JS: themeManager.applyPageTheme - Theme to apply:", themeToApply);
|
| 59 |
+
|
| 60 |
+
// PASTIKAN URL INI SESUAI DENGAN THEME_LIGHT DAN THEME_DARK DI APP.PY
|
| 61 |
+
const lightThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/flatly/bootstrap.min.css";
|
| 62 |
+
const darkThemeUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/darkly/bootstrap.min.css";
|
| 63 |
+
|
| 64 |
+
let newThemeUrl = lightThemeUrl;
|
| 65 |
+
document.body.classList.remove('theme-dark', 'theme-light');
|
| 66 |
+
|
| 67 |
+
if (themeToApply === 'DARK') {
|
| 68 |
+
newThemeUrl = darkThemeUrl;
|
| 69 |
+
document.body.classList.add('theme-dark');
|
| 70 |
+
} else {
|
| 71 |
+
newThemeUrl = lightThemeUrl;
|
| 72 |
+
document.body.classList.add('theme-light');
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
let themeLink = document.getElementById('bootstrap-theme');
|
| 76 |
+
if (!themeLink) {
|
| 77 |
+
console.log("JS: themeManager.applyPageTheme - Creating new theme link element.");
|
| 78 |
+
themeLink = document.createElement('link');
|
| 79 |
+
themeLink.id = 'bootstrap-theme';
|
| 80 |
+
themeLink.rel = 'stylesheet';
|
| 81 |
+
themeLink.type = 'text/css';
|
| 82 |
+
document.getElementsByTagName('head')[0].appendChild(themeLink);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (themeLink.href !== newThemeUrl) {
|
| 86 |
+
console.log("JS: themeManager.applyPageTheme - Setting theme link href to:", newThemeUrl);
|
| 87 |
+
themeLink.href = newThemeUrl;
|
| 88 |
+
}
|
| 89 |
+
return window.dash_clientside.no_update;
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
console.log(">>> assets/clientside.js (themeManager version) - window.themeManager object DEFINED:", window.themeManager);
|
| 94 |
+
if (typeof window.themeManager.initPageTheme !== 'function') {
|
| 95 |
+
console.error(">>> DEBUG: assets/clientside.js - initPageTheme IS NOT a function on window.themeManager!");
|
| 96 |
+
}
|
| 97 |
+
if (typeof window.themeManager.applyPageTheme !== 'function') {
|
| 98 |
+
console.error(">>> DEBUG: assets/clientside.js - applyPageTheme IS NOT a function on window.themeManager!");
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
} catch (e) {
|
| 102 |
+
console.error(">>> DEBUG: assets/clientside.js (themeManager version) - ERROR DURING SCRIPT EXECUTION:", e);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
console.log(">>> assets/clientside.js (themeManager version) - SCRIPT EXECUTION FINISHED <<<");
|
assets/default_avatar_dark.jpg
ADDED
|
|
assets/default_avatar_light.jpg
ADDED
|
|
assets/style.css
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* assets/style.css */
|
| 2 |
+
|
| 3 |
+
/* ================================================
|
| 4 |
+
Global Styles & Resets
|
| 5 |
+
================================================ */
|
| 6 |
+
html, body {
|
| 7 |
+
height: 100%;
|
| 8 |
+
margin: 0;
|
| 9 |
+
padding: 0;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/* ================================================
|
| 13 |
+
Sidebar Styling
|
| 14 |
+
================================================ */
|
| 15 |
+
.sidebar {
|
| 16 |
+
position: fixed;
|
| 17 |
+
top: 0;
|
| 18 |
+
left: 0;
|
| 19 |
+
bottom: 0;
|
| 20 |
+
width: 15rem;
|
| 21 |
+
padding: 2rem 1rem;
|
| 22 |
+
z-index: 1030;
|
| 23 |
+
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.sidebar-title {
|
| 27 |
+
font-size: 1.3rem;
|
| 28 |
+
margin-bottom: 1rem;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.sidebar-link {
|
| 32 |
+
display: block;
|
| 33 |
+
padding: 0.7rem 1rem;
|
| 34 |
+
text-decoration: none;
|
| 35 |
+
margin-bottom: 0.5rem;
|
| 36 |
+
border-radius: 0.3rem;
|
| 37 |
+
transition: background-color 0.2s ease, color 0.2s ease;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.sidebar-link.active,
|
| 41 |
+
.sidebar-link--active {
|
| 42 |
+
font-weight: 500;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* --- Profil di Sidebar --- */
|
| 46 |
+
.sidebar-profile-container {
|
| 47 |
+
border-bottom-width: 1px;
|
| 48 |
+
border-bottom-style: solid;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.sidebar-profile-avatar,
|
| 52 |
+
.sidebar-profile-avatar-icon {
|
| 53 |
+
width: 32px;
|
| 54 |
+
height: 32px;
|
| 55 |
+
min-width: 32px;
|
| 56 |
+
min-height: 32px;
|
| 57 |
+
border-radius: 50%;
|
| 58 |
+
vertical-align: middle;
|
| 59 |
+
}
|
| 60 |
+
.sidebar-profile-avatar {
|
| 61 |
+
object-fit: cover;
|
| 62 |
+
border-width: 1px;
|
| 63 |
+
border-style: solid;
|
| 64 |
+
}
|
| 65 |
+
.sidebar-profile-avatar-icon {
|
| 66 |
+
background-size: cover;
|
| 67 |
+
background-position: center;
|
| 68 |
+
background-repeat: no-repeat;
|
| 69 |
+
border-width: 1px;
|
| 70 |
+
border-style: solid;
|
| 71 |
+
border-color: transparent;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.sidebar-profile-text {
|
| 75 |
+
font-weight: 500;
|
| 76 |
+
font-size: 0.9rem;
|
| 77 |
+
overflow: hidden;
|
| 78 |
+
text-overflow: ellipsis;
|
| 79 |
+
white-space: nowrap;
|
| 80 |
+
max-width: calc(100% - 40px - 0.5rem);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* ================================================
|
| 84 |
+
Page Content Styling
|
| 85 |
+
================================================ */
|
| 86 |
+
#page-content {
|
| 87 |
+
margin-left: 15rem;
|
| 88 |
+
padding: 2rem;
|
| 89 |
+
min-height: 100vh;
|
| 90 |
+
width: calc(100% - 15rem);
|
| 91 |
+
position: relative;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* ================================================
|
| 95 |
+
Tema Terang Styles (Default & Overrides)
|
| 96 |
+
================================================ */
|
| 97 |
+
body.theme-light .sidebar {
|
| 98 |
+
background-color: #f8f9fa;
|
| 99 |
+
color: #212529;
|
| 100 |
+
border-right: 1px solid #dee2e6;
|
| 101 |
+
}
|
| 102 |
+
body.theme-light .sidebar-title {
|
| 103 |
+
color: #0d6efd;
|
| 104 |
+
}
|
| 105 |
+
body.theme-light .sidebar-link {
|
| 106 |
+
color: #495057;
|
| 107 |
+
}
|
| 108 |
+
body.theme-light .sidebar-link:hover {
|
| 109 |
+
background-color: #e9ecef;
|
| 110 |
+
color: #0056b3;
|
| 111 |
+
}
|
| 112 |
+
body.theme-light .sidebar-link.active,
|
| 113 |
+
body.theme-light .sidebar-link--active {
|
| 114 |
+
background-color: #0d6efd;
|
| 115 |
+
color: white;
|
| 116 |
+
}
|
| 117 |
+
body.theme-light .sidebar-profile-container {
|
| 118 |
+
border-bottom-color: #dee2e6;
|
| 119 |
+
}
|
| 120 |
+
body.theme-light .sidebar-profile-avatar {
|
| 121 |
+
border-color: #adb5bd;
|
| 122 |
+
}
|
| 123 |
+
body.theme-light .sidebar-profile-avatar-icon {
|
| 124 |
+
background-image: url('/assets/default_avatar_light.jpg');
|
| 125 |
+
border-color: #adb5bd;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* ================================================
|
| 129 |
+
Tema Gelap Styles
|
| 130 |
+
================================================ */
|
| 131 |
+
body.theme-dark .sidebar {
|
| 132 |
+
background-color: #212529;
|
| 133 |
+
color: #f8f9fa;
|
| 134 |
+
border-right: 1px solid #343a40;
|
| 135 |
+
}
|
| 136 |
+
body.theme-dark .sidebar-title {
|
| 137 |
+
color: #6ea8fe;
|
| 138 |
+
}
|
| 139 |
+
body.theme-dark .sidebar-link {
|
| 140 |
+
color: #adb5bd;
|
| 141 |
+
}
|
| 142 |
+
body.theme-dark .sidebar-link:hover {
|
| 143 |
+
background-color: #343a40;
|
| 144 |
+
color: #ffffff;
|
| 145 |
+
}
|
| 146 |
+
body.theme-dark .sidebar-link.active,
|
| 147 |
+
body.theme-dark .sidebar-link--active {
|
| 148 |
+
background-color: #0d6efd;
|
| 149 |
+
color: white;
|
| 150 |
+
}
|
| 151 |
+
body.theme-dark .sidebar-profile-container {
|
| 152 |
+
border-bottom-color: #495057;
|
| 153 |
+
}
|
| 154 |
+
body.theme-dark .sidebar-profile-avatar {
|
| 155 |
+
border-color: #6c757d;
|
| 156 |
+
}
|
| 157 |
+
body.theme-dark .sidebar-profile-avatar-icon {
|
| 158 |
+
background-image: url('/assets/default_avatar_dark.jpg');
|
| 159 |
+
border-color: #6c757d;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* --- Styling Komponen Form untuk Tema Gelap --- */
|
| 163 |
+
body.theme-dark .Select-control { /* Kontainer utama dcc.Dropdown */
|
| 164 |
+
background-color: #454d55; /* Sedikit lebih terang dari #212529 atau #343a40 */
|
| 165 |
+
border: 1px solid #5a6268;
|
| 166 |
+
}
|
| 167 |
+
body.theme-dark .Select-control .Select-value-label,
|
| 168 |
+
body.theme-dark .Select-control .Select-placeholder {
|
| 169 |
+
color: #e9ecef !important; /* Warna teks terang */
|
| 170 |
+
}
|
| 171 |
+
body.theme-dark .Select-control .Select-arrow-zone .Select-arrow {
|
| 172 |
+
border-top-color: #e9ecef;
|
| 173 |
+
}
|
| 174 |
+
body.theme-dark .Select-menu-outer { /* Menu pilihan dropdown */
|
| 175 |
+
background-color: #3e444a;
|
| 176 |
+
border: 1px solid #5a6268;
|
| 177 |
+
}
|
| 178 |
+
body.theme-dark .Select-menu-outer .Select-option {
|
| 179 |
+
color: #f8f9fa;
|
| 180 |
+
background-color: #3e444a;
|
| 181 |
+
}
|
| 182 |
+
body.theme-dark .Select-menu-outer .Select-option.is-focused {
|
| 183 |
+
background-color: #495057;
|
| 184 |
+
color: #ffffff;
|
| 185 |
+
}
|
| 186 |
+
body.theme-dark .Select-menu-outer .Select-option.is-selected {
|
| 187 |
+
background-color: #0d6efd;
|
| 188 |
+
color: white;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* Untuk dcc.Checklist di tema gelap */
|
| 192 |
+
body.theme-dark .rc-checkbox-inner,
|
| 193 |
+
body.theme-dark .rc-checkbox-input {
|
| 194 |
+
border-color: #adb5bd !important;
|
| 195 |
+
}
|
| 196 |
+
body.theme-dark .rc-checkbox-checked .rc-checkbox-inner {
|
| 197 |
+
background-color: #0d6efd;
|
| 198 |
+
border-color: #0d6efd;
|
| 199 |
+
}
|
| 200 |
+
body.theme-dark .rc-checkbox-checked .rc-checkbox-inner::after {
|
| 201 |
+
border-color: white; /* Warna tanda centang */
|
| 202 |
+
}
|
| 203 |
+
/* Jika label text dari dcc.Checklist juga perlu diubah warnanya (biasanya sudah ikut warna body) */
|
| 204 |
+
/* body.theme-dark .your-checklist-label-wrapper-class label {
|
| 205 |
+
color: #f8f9fa;
|
| 206 |
+
} */
|
| 207 |
+
|
| 208 |
+
/* Placeholder untuk dbc.Input jika menggunakan tema Bootstrap */
|
| 209 |
+
body.theme-dark .form-control::placeholder {
|
| 210 |
+
color: #adb5bd;
|
| 211 |
+
opacity: 1;
|
| 212 |
+
}
|
| 213 |
+
body.theme-dark .form-control:-ms-input-placeholder {
|
| 214 |
+
color: #adb5bd;
|
| 215 |
+
}
|
| 216 |
+
body.theme-dark .form-control::-ms-input-placeholder {
|
| 217 |
+
color: #adb5bd;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
/* ================================================
|
| 222 |
+
Penyesuaian Font Kartu Beranda & Judul Halaman
|
| 223 |
+
================================================ */
|
| 224 |
+
/* ... (kode lain di atasnya) ... */
|
| 225 |
+
|
| 226 |
+
.card .card-body h2,
|
| 227 |
+
.card .card-body .h2,
|
| 228 |
+
.card .card-body .main-content-text { /* Class kustom jika perlu */
|
| 229 |
+
font-size: 1rem; /* KONTEN UTAMA LEBIH KECIL DARI JUDUL KARTU */
|
| 230 |
+
font-weight: 400;
|
| 231 |
+
line-height: 1.4;
|
| 232 |
+
margin-bottom: 0.25rem;
|
| 233 |
+
color: #ffffff; /* DIUBAH: dari #333 menjadi putih */
|
| 234 |
+
}
|
| 235 |
+
body.theme-dark .card .card-body h2,
|
| 236 |
+
body.theme-dark .card .card-body .h2,
|
| 237 |
+
body.theme-dark .card .card-body .main-content-text {
|
| 238 |
+
color: #e9ecef; /* Warna konten untuk tema gelap (ini tetap) */
|
| 239 |
+
}
|
| 240 |
+
.card .card-body .sub-content-text, /* Class kustom untuk angka/detail */
|
| 241 |
+
.card-text small {
|
| 242 |
+
font-size: 0.9rem;
|
| 243 |
+
font-weight: 400;
|
| 244 |
+
color: #f0f0f0; /* DIUBAH: dari #555 menjadi putih pudar (atau #ffffff jika mau putih solid) */
|
| 245 |
+
display: block;
|
| 246 |
+
margin-top: 0.1rem;
|
| 247 |
+
}
|
| 248 |
+
body.theme-dark .card .card-body .sub-content-text,
|
| 249 |
+
body.theme-dark .card-text small {
|
| 250 |
+
color: #bbb; /* Warna konten untuk tema gelap (ini tetap) */
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/* ================================================
|
| 254 |
+
Responsif (Sidebar & Page Content & Kartu)
|
| 255 |
+
================================================ */
|
| 256 |
+
@media (max-width: 768px) {
|
| 257 |
+
.sidebar {
|
| 258 |
+
position: relative;
|
| 259 |
+
width: 100%;
|
| 260 |
+
height: auto;
|
| 261 |
+
display: flex;
|
| 262 |
+
flex-direction: row;
|
| 263 |
+
flex-wrap: wrap;
|
| 264 |
+
justify-content: space-around;
|
| 265 |
+
padding: 0.5rem 0.25rem;
|
| 266 |
+
border-right: none;
|
| 267 |
+
}
|
| 268 |
+
body.theme-light .sidebar {
|
| 269 |
+
border-bottom: 1px solid #dee2e6;
|
| 270 |
+
}
|
| 271 |
+
body.theme-dark .sidebar {
|
| 272 |
+
border-bottom: 1px solid #343a40;
|
| 273 |
+
}
|
| 274 |
+
.sidebar-title {
|
| 275 |
+
display: none;
|
| 276 |
+
}
|
| 277 |
+
.sidebar-profile-container {
|
| 278 |
+
display: none !important;
|
| 279 |
+
}
|
| 280 |
+
.sidebar-link {
|
| 281 |
+
padding: 0.6rem 0.5rem;
|
| 282 |
+
margin-bottom: 0;
|
| 283 |
+
flex-basis: auto;
|
| 284 |
+
flex-grow: 1;
|
| 285 |
+
text-align: center;
|
| 286 |
+
font-size: 0.85rem;
|
| 287 |
+
}
|
| 288 |
+
#page-content {
|
| 289 |
+
margin-left: 0;
|
| 290 |
+
width: 100%;
|
| 291 |
+
padding: 1rem;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* Ukuran font kartu di mobile */
|
| 295 |
+
.card .card-title,
|
| 296 |
+
.card .card-header {
|
| 297 |
+
font-size: 1rem;
|
| 298 |
+
}
|
| 299 |
+
.card .card-body h2,
|
| 300 |
+
.card .card-body .h2,
|
| 301 |
+
.card .card-body .main-content-text {
|
| 302 |
+
font-size: 0.9rem;
|
| 303 |
+
}
|
| 304 |
+
.card .card-body .sub-content-text,
|
| 305 |
+
.card-text small {
|
| 306 |
+
font-size: 0.8rem;
|
| 307 |
+
}
|
| 308 |
+
}
|
auth/login.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# auth/login.py
|
| 2 |
+
from dash import html, dcc, Input, Output, State, callback, no_update
|
| 3 |
+
import dash_bootstrap_components as dbc # Import dbc
|
| 4 |
+
from database import engine, users
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from werkzeug.security import check_password_hash # PENTING: Import untuk verifikasi hash
|
| 7 |
+
|
| 8 |
+
login_layout = dbc.Container(
|
| 9 |
+
fluid=True,
|
| 10 |
+
className="d-flex flex-column justify-content-center align-items-center min-vh-100 p-0 m-0",
|
| 11 |
+
style={'background': 'linear-gradient(135deg, #1e2a47, #2c3e50)'},
|
| 12 |
+
children=[
|
| 13 |
+
dcc.Location(id='login-url-redirect', refresh=True),
|
| 14 |
+
dbc.Row(
|
| 15 |
+
dbc.Col(
|
| 16 |
+
dbc.Card(
|
| 17 |
+
dbc.CardBody([
|
| 18 |
+
html.H2("Disease Dashboard Login", className="text-center mb-4", style={'color': '#2c3e50'}),
|
| 19 |
+
html.Div(id='login-message', className="mb-3 text-center"),
|
| 20 |
+
dbc.Form([
|
| 21 |
+
dbc.Row([
|
| 22 |
+
dbc.Label("Username", width=4, className="text-md-end"),
|
| 23 |
+
dbc.Col(
|
| 24 |
+
dbc.Input(id='login-username', type='text', placeholder="Masukkan username Anda"),
|
| 25 |
+
width=8
|
| 26 |
+
)
|
| 27 |
+
], className="mb-3 align-items-center"),
|
| 28 |
+
|
| 29 |
+
dbc.Row([
|
| 30 |
+
dbc.Label("Password", width=4, className="text-md-end"),
|
| 31 |
+
dbc.Col(
|
| 32 |
+
dbc.Input(id='login-password', type='password', placeholder="Masukkan password Anda"),
|
| 33 |
+
width=8
|
| 34 |
+
)
|
| 35 |
+
], className="mb-3 align-items-center"),
|
| 36 |
+
dbc.Button("Login", id='login-button', color="primary", className="w-100 mt-4", n_clicks=0, size="lg"),
|
| 37 |
+
]),
|
| 38 |
+
html.Div(
|
| 39 |
+
dcc.Link("Belum punya akun? Daftar di sini", href="/signup", className="d-block mt-3 text-center"),
|
| 40 |
+
)
|
| 41 |
+
]),
|
| 42 |
+
className="shadow-lg",
|
| 43 |
+
style={'padding': '2rem'}
|
| 44 |
+
),
|
| 45 |
+
width=12, sm=10, md=8, lg=5, xl=4 # Sesuaikan lebar card login
|
| 46 |
+
),
|
| 47 |
+
justify="center",
|
| 48 |
+
className="w-100"
|
| 49 |
+
)
|
| 50 |
+
]
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
layout = login_layout
|
| 55 |
+
# Callback untuk handle login
|
| 56 |
+
@callback(
|
| 57 |
+
Output('login-status', 'data', allow_duplicate=True), # Targetkan store global di app.py
|
| 58 |
+
Output('login-url-redirect', 'pathname', allow_duplicate=True),
|
| 59 |
+
Output('login-message', 'children', allow_duplicate=True),
|
| 60 |
+
Input('login-button', 'n_clicks'),
|
| 61 |
+
State('login-username', 'value'),
|
| 62 |
+
State('login-password', 'value'),
|
| 63 |
+
prevent_initial_call=True
|
| 64 |
+
)
|
| 65 |
+
def handle_login(n_clicks_login, username, password_input):
|
| 66 |
+
if not username or not password_input:
|
| 67 |
+
return no_update, no_update, dbc.Alert("Username dan password harus diisi.", color="warning", dismissable=True, duration=4000)
|
| 68 |
+
|
| 69 |
+
username = username.strip()
|
| 70 |
+
|
| 71 |
+
with engine.connect() as conn:
|
| 72 |
+
# Ambil id_user, username, password (hash), dan nama_lengkap
|
| 73 |
+
stmt = select(
|
| 74 |
+
users.c.id_user, # Pastikan ini adalah nama PK di tabel users
|
| 75 |
+
users.c.username,
|
| 76 |
+
users.c.password,
|
| 77 |
+
users.c.nama_lengkap
|
| 78 |
+
).where(users.c.username == username)
|
| 79 |
+
user_record = conn.execute(stmt).fetchone()
|
| 80 |
+
|
| 81 |
+
print(f"--- LOGIN DEBUG: Mencoba login untuk user: {username} ---")
|
| 82 |
+
if user_record:
|
| 83 |
+
hashed_password_from_db = user_record.password
|
| 84 |
+
# print(f"--- LOGIN DEBUG: Hashed Password dari DB: {str(hashed_password_from_db)[:20]}... ---")
|
| 85 |
+
# print(f"--- LOGIN DEBUG: Password Input: '{password_input}' ---")
|
| 86 |
+
|
| 87 |
+
if check_password_hash(str(hashed_password_from_db), str(password_input).strip()):
|
| 88 |
+
session_data = {
|
| 89 |
+
'logged_in': True,
|
| 90 |
+
'username': user_record.username,
|
| 91 |
+
'nama_lengkap': user_record.nama_lengkap,
|
| 92 |
+
'id_user': user_record.id_user # <--- TAMBAHKAN BARIS INI
|
| 93 |
+
}
|
| 94 |
+
print(f"--- LOGIN DEBUG: Login BERHASIL. Session data: {session_data} ---")
|
| 95 |
+
return session_data, '/beranda', dbc.Alert(f"Login berhasil, selamat datang {user_record.nama_lengkap}!", color="success", duration=4000)
|
| 96 |
+
else:
|
| 97 |
+
print(f"--- LOGIN DEBUG: Password SALAH untuk user: {username} ---")
|
| 98 |
+
return no_update, no_update, dbc.Alert("Username atau password salah.", color="danger", dismissable=True, duration=4000)
|
| 99 |
+
else:
|
| 100 |
+
print(f"--- LOGIN DEBUG: Username '{username}' TIDAK DITEMUKAN ---")
|
| 101 |
+
return no_update, no_update, dbc.Alert("Username atau password salah.", color="danger", dismissable=True, duration=4000)
|
| 102 |
+
# Layout export
|
| 103 |
+
layout = login_layout
|
auth/signup.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# auth/signup.py
|
| 2 |
+
from dash import html, dcc, Input, Output, State, callback, no_update
|
| 3 |
+
import dash_bootstrap_components as dbc
|
| 4 |
+
from database import engine, users
|
| 5 |
+
from sqlalchemy import select, insert
|
| 6 |
+
from werkzeug.security import generate_password_hash
|
| 7 |
+
|
| 8 |
+
# Opsi untuk Jabatan
|
| 9 |
+
role_options = [
|
| 10 |
+
{'label': 'Petugas Kesehatan Dasar', 'value': 'petugas_kesdas'},
|
| 11 |
+
{'label': 'Petugas Rekam Medis Puskesmas', 'value': 'pmik'},
|
| 12 |
+
{'label': 'Peneliti', 'value': 'peneliti'},
|
| 13 |
+
{'label': 'Lainnya', 'value': 'lainnya'}
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
signup_layout = dbc.Container(
|
| 17 |
+
fluid=True, # Mengambil lebar penuh viewport
|
| 18 |
+
className="d-flex flex-column justify-content-center align-items-center min-vh-100 p-0 m-0", # Center vertikal & horizontal, tinggi minimal 100% viewport, hapus padding/margin default container
|
| 19 |
+
style={'background': 'linear-gradient(135deg, #1e2a47, #2c3e50)'},
|
| 20 |
+
children=[
|
| 21 |
+
dcc.Location(id='signup-url-redirect', refresh=True),
|
| 22 |
+
dbc.Row( # Baris untuk menengahkan Card
|
| 23 |
+
dbc.Col( # Kolom untuk Card, batasi lebar
|
| 24 |
+
dbc.Card(
|
| 25 |
+
dbc.CardBody([
|
| 26 |
+
html.H2("Daftar Akun Baru", className="text-center mb-4", style={'color': '#2c3e50'}),
|
| 27 |
+
# Pesan error/sukses di atas form
|
| 28 |
+
html.Div(id='signup-message', className="mb-3 text-center"),
|
| 29 |
+
|
| 30 |
+
dbc.Form([
|
| 31 |
+
# Nama Lengkap
|
| 32 |
+
dbc.Row([
|
| 33 |
+
dbc.Label("Nama Lengkap", width=4, className="text-md-end"), # Label di kiri pada layar medium+
|
| 34 |
+
dbc.Col(
|
| 35 |
+
dbc.Input(id='signup-fullname', type='text', placeholder='Masukkan nama lengkap Anda'),
|
| 36 |
+
width=8
|
| 37 |
+
)
|
| 38 |
+
], className="mb-3 align-items-center"),
|
| 39 |
+
|
| 40 |
+
# Username
|
| 41 |
+
dbc.Row([
|
| 42 |
+
dbc.Label("Username", width=4, className="text-md-end"),
|
| 43 |
+
dbc.Col(
|
| 44 |
+
dbc.Input(id='signup-username', type='text', placeholder='Pilih username (unik)'),
|
| 45 |
+
width=8
|
| 46 |
+
)
|
| 47 |
+
], className="mb-3 align-items-center"),
|
| 48 |
+
|
| 49 |
+
# Email
|
| 50 |
+
dbc.Row([
|
| 51 |
+
dbc.Label("Email (Opsional)", width=4, className="text-md-end"),
|
| 52 |
+
dbc.Col(
|
| 53 |
+
dbc.Input(id='signup-email', type='email', placeholder='Masukkan alamat email'),
|
| 54 |
+
width=8
|
| 55 |
+
)
|
| 56 |
+
], className="mb-3 align-items-center"),
|
| 57 |
+
|
| 58 |
+
# Password
|
| 59 |
+
dbc.Row([
|
| 60 |
+
dbc.Label("Password", width=4, className="text-md-end"),
|
| 61 |
+
dbc.Col(
|
| 62 |
+
dbc.Input(id='signup-password', type='password', placeholder='Buat password (minimal 6 karakter)'),
|
| 63 |
+
width=8
|
| 64 |
+
)
|
| 65 |
+
], className="mb-3 align-items-center"),
|
| 66 |
+
|
| 67 |
+
# Konfirmasi Password
|
| 68 |
+
dbc.Row([
|
| 69 |
+
dbc.Label("Konfirmasi Password", width=4, className="text-md-end"),
|
| 70 |
+
dbc.Col(
|
| 71 |
+
dbc.Input(id='signup-confirm-password', type='password', placeholder='Ulangi password'),
|
| 72 |
+
width=8
|
| 73 |
+
)
|
| 74 |
+
], className="mb-3 align-items-center"),
|
| 75 |
+
|
| 76 |
+
# Jabatan
|
| 77 |
+
dbc.Row([
|
| 78 |
+
dbc.Label("Jabatan", width=4, className="text-md-end"),
|
| 79 |
+
dbc.Col(
|
| 80 |
+
dcc.Dropdown(
|
| 81 |
+
id='signup-role',
|
| 82 |
+
options=role_options,
|
| 83 |
+
placeholder='Pilih jabatan Anda',
|
| 84 |
+
),
|
| 85 |
+
width=8
|
| 86 |
+
)
|
| 87 |
+
], className="mb-3 align-items-center"),
|
| 88 |
+
|
| 89 |
+
dbc.Button("Daftar", id='signup-button', color="success", className="w-100 mt-4", n_clicks=0, size="lg"),
|
| 90 |
+
]),
|
| 91 |
+
html.Div(
|
| 92 |
+
dcc.Link("Sudah punya akun? Login di sini", href="/login", className="d-block mt-3 text-center"),
|
| 93 |
+
)
|
| 94 |
+
]),
|
| 95 |
+
className="shadow-lg", # Shadow pada card
|
| 96 |
+
style={'padding': '2rem'} # Tambahkan padding dalam card
|
| 97 |
+
),
|
| 98 |
+
width=12, # Lebar kolom untuk layar kecil
|
| 99 |
+
sm=10, # Sedikit lebih sempit di layar small
|
| 100 |
+
md=8, # Lebih sempit lagi di layar medium
|
| 101 |
+
lg=6, # Paling ideal di layar large
|
| 102 |
+
xl=5 # Mungkin terlalu sempit, bisa disesuaikan
|
| 103 |
+
),
|
| 104 |
+
justify="center", # Tengahkan kolom di dalam baris
|
| 105 |
+
className="w-100" # Pastikan baris mengambil lebar penuh
|
| 106 |
+
)
|
| 107 |
+
]
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Callback untuk handle signup (TETAP SAMA SEPERTI SEBELUMNYA)
|
| 111 |
+
@callback(
|
| 112 |
+
Output('signup-url-redirect', 'pathname', allow_duplicate=True),
|
| 113 |
+
Output('signup-message', 'children', allow_duplicate=True),
|
| 114 |
+
Input('signup-button', 'n_clicks'),
|
| 115 |
+
State('signup-fullname', 'value'),
|
| 116 |
+
State('signup-username', 'value'),
|
| 117 |
+
State('signup-password', 'value'),
|
| 118 |
+
State('signup-confirm-password', 'value'),
|
| 119 |
+
State('signup-email', 'value'),
|
| 120 |
+
State('signup-role', 'value'),
|
| 121 |
+
prevent_initial_call=True
|
| 122 |
+
)
|
| 123 |
+
def handle_signup(n_clicks_signup, fullname, username, password, confirm_password, email, role):
|
| 124 |
+
if not fullname or not username or not password or not confirm_password or not role:
|
| 125 |
+
return no_update, dbc.Alert("Semua field (Nama Lengkap, Username, Password, Konfirmasi, Jabatan) harus diisi.", color="danger", dismissable=True, duration=4000)
|
| 126 |
+
|
| 127 |
+
if len(password) < 6:
|
| 128 |
+
return no_update, dbc.Alert("Password minimal 6 karakter.", color="danger", dismissable=True, duration=4000)
|
| 129 |
+
|
| 130 |
+
if password != confirm_password:
|
| 131 |
+
return no_update, dbc.Alert("Password dan konfirmasi password tidak cocok.", color="danger", dismissable=True, duration=4000)
|
| 132 |
+
|
| 133 |
+
username = username.strip()
|
| 134 |
+
email = email.strip() if email else None
|
| 135 |
+
|
| 136 |
+
with engine.connect() as conn:
|
| 137 |
+
stmt_check_username = select(users.c.id_user).where(users.c.username == username)
|
| 138 |
+
if conn.execute(stmt_check_username).fetchone():
|
| 139 |
+
return no_update, dbc.Alert(f"Username '{username}' sudah digunakan.", color="warning", dismissable=True, duration=4000)
|
| 140 |
+
|
| 141 |
+
if email:
|
| 142 |
+
stmt_check_email = select(users.c.id_user).where(users.c.email == email)
|
| 143 |
+
if conn.execute(stmt_check_email).fetchone():
|
| 144 |
+
return no_update, dbc.Alert(f"Email '{email}' sudah terdaftar.", color="warning", dismissable=True, duration=4000)
|
| 145 |
+
|
| 146 |
+
hashed_password_to_store = generate_password_hash(password)
|
| 147 |
+
# print(f"--- SIGNUP DEBUG: Username: {username}, Password Asli: '{password}', Hashed untuk DB: {hashed_password_to_store[:20]}... ---")
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
new_user_values = {
|
| 151 |
+
'nama_lengkap': fullname.strip(),
|
| 152 |
+
'username': username,
|
| 153 |
+
'password': hashed_password_to_store,
|
| 154 |
+
'jabatan': role
|
| 155 |
+
}
|
| 156 |
+
if email:
|
| 157 |
+
new_user_values['email'] = email
|
| 158 |
+
|
| 159 |
+
insert_stmt = users.insert().values(**new_user_values)
|
| 160 |
+
conn.execute(insert_stmt)
|
| 161 |
+
conn.commit()
|
| 162 |
+
|
| 163 |
+
return '/login', dbc.Alert("Registrasi berhasil! Silakan login.", color="success", duration=5000)
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
conn.rollback()
|
| 167 |
+
# print(f"--- SIGNUP ERROR: {e} ---")
|
| 168 |
+
# import traceback
|
| 169 |
+
# traceback.print_exc()
|
| 170 |
+
return no_update, dbc.Alert(f"Terjadi kesalahan saat registrasi.", color="danger", dismissable=True, duration=4000)
|
| 171 |
+
|
| 172 |
+
return no_update, ""
|
| 173 |
+
|
| 174 |
+
layout = signup_layout
|
components/sidebar.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# components/sidebar.py
|
| 2 |
+
from dash import html, dcc
|
| 3 |
+
import dash_bootstrap_components as dbc # Import dbc jika ingin menggunakan ikonnya
|
| 4 |
+
|
| 5 |
+
# Function untuk bikin sidebar layout
|
| 6 |
+
def create_sidebar():
|
| 7 |
+
return html.Div(
|
| 8 |
+
[
|
| 9 |
+
# --- Bagian Profil Pengguna ---
|
| 10 |
+
html.Div([
|
| 11 |
+
# dbc.Spinner( # Spinner saat loading nama, opsional
|
| 12 |
+
html.Div([
|
| 13 |
+
html.Div(className="sidebar-profile-avatar-icon me-2"), # Div untuk ikon avatar
|
| 14 |
+
html.Span(id="sidebar-profile-name", children="Nama Pengguna", className="sidebar-profile-text")
|
| 15 |
+
], className="d-flex align-items-center p-2 mb-3 sidebar-profile-container")
|
| 16 |
+
# )
|
| 17 |
+
], id="sidebar-profile-section"),
|
| 18 |
+
# --- Akhir Bagian Profil Pengguna ---
|
| 19 |
+
|
| 20 |
+
html.H2("Dashboard", className="sidebar-title"), # Judul bisa dikecilkan atau digeser
|
| 21 |
+
dcc.Link("Beranda", href="/beranda", className="sidebar-link"),
|
| 22 |
+
dcc.Link("Analisis Tren Penyakit", href="/analisis_tren_penyakit", className="sidebar-link"),
|
| 23 |
+
dcc.Link("Distribusi Kasus Demografi", href="/distribusi_kasus_demografi", className="sidebar-link"),
|
| 24 |
+
dcc.Link("Laporan dan Unduh Data", href="/laporan", className="sidebar-link"),
|
| 25 |
+
dcc.Link("Input Data", href="/input_data", className="sidebar-link"),
|
| 26 |
+
dcc.Link("Pengaturan", href="/pengaturan", className="sidebar-link"),
|
| 27 |
+
dcc.Link("Logout", href="/logout", className="sidebar-link")
|
| 28 |
+
],
|
| 29 |
+
className="sidebar",
|
| 30 |
+
id="sidebar"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
sidebar_layout = create_sidebar()
|
data/disease_trends.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:15edd0f14adcdc530cf7db0e9c01c773aba17190bae877f2b9ee1599d4f960ca
|
| 3 |
+
size 7798784
|
database.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# File: database.py (Versi Final untuk Supabase & Hugging Face)
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from sqlalchemy import create_engine, MetaData, text
|
| 7 |
+
|
| 8 |
+
# -----------------------------------------------------------------------------
|
| 9 |
+
# LANGKAH 1: SETUP KONEKSI DAN METADATA
|
| 10 |
+
# -----------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
# Muat variabel dari file .env (penting untuk pengembangan lokal)
|
| 13 |
+
load_dotenv()
|
| 14 |
+
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
|
| 15 |
+
print(f"Mencari file .env di lokasi: {dotenv_path}")
|
| 16 |
+
|
| 17 |
+
if os.path.exists(dotenv_path):
|
| 18 |
+
print("File .env DITEMUKAN!")
|
| 19 |
+
load_dotenv(dotenv_path=dotenv_path)
|
| 20 |
+
print("Isi DATABASE_URL setelah load:", os.getenv("DATABASE_URL"))
|
| 21 |
+
else:
|
| 22 |
+
print("PERINGATAN: File .env TIDAK DITEMUKAN di lokasi tersebut.")
|
| 23 |
+
|
| 24 |
+
# Ambil URL koneksi dari environment variables (atau Hugging Face Secrets)
|
| 25 |
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 26 |
+
if not DATABASE_URL:
|
| 27 |
+
raise ValueError("DATABASE_URL tidak ditemukan. Pastikan sudah di-set di environment/secrets.")
|
| 28 |
+
|
| 29 |
+
# Buat engine koneksi ke database PostgreSQL
|
| 30 |
+
# echo=False bagus untuk produksi agar tidak menampilkan semua query di log
|
| 31 |
+
engine = create_engine(DATABASE_URL, echo=False)
|
| 32 |
+
|
| 33 |
+
# Buat objek metadata
|
| 34 |
+
metadata = MetaData()
|
| 35 |
+
|
| 36 |
+
# -----------------------------------------------------------------------------
|
| 37 |
+
# LANGKAH 2: REFLEKSI TABEL DARI DATABASE
|
| 38 |
+
# -----------------------------------------------------------------------------
|
| 39 |
+
# Daripada mendefinisikan tabel manual, kita 'mencerminkan' skema
|
| 40 |
+
# yang SUDAH ADA di database Supabase. Ini lebih aman dan fleksibel.
|
| 41 |
+
try:
|
| 42 |
+
print("Mencoba melakukan refleksi tabel dari database...")
|
| 43 |
+
metadata.reflect(bind=engine)
|
| 44 |
+
|
| 45 |
+
# Ambil referensi ke tabel yang sudah ada
|
| 46 |
+
users = metadata.tables['users']
|
| 47 |
+
data_penyakit = metadata.tables['data_penyakit']
|
| 48 |
+
detail_penyakit = metadata.tables['detail_penyakit']
|
| 49 |
+
print("Refleksi tabel berhasil.")
|
| 50 |
+
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"Error saat melakukan refleksi tabel: {e}")
|
| 53 |
+
print("Pastikan nama tabel (users, data_penyakit, detail_penyakit) sudah benar dan ada di database Supabase.")
|
| 54 |
+
# Set ke None jika gagal agar aplikasi tidak crash saat di-import
|
| 55 |
+
users, data_penyakit, detail_penyakit = None, None, None
|
| 56 |
+
|
| 57 |
+
# -----------------------------------------------------------------------------
|
| 58 |
+
# LANGKAH 3: FUNGSI-FUNGSI HELPER (JIKA ADA)
|
| 59 |
+
# -----------------------------------------------------------------------------
|
| 60 |
+
# Contoh: Fungsi untuk mengambil data (versi yang lebih aman)
|
| 61 |
+
def ambil_data_penyakit(puskesmas, tahun):
|
| 62 |
+
"""
|
| 63 |
+
Mengambil data penyakit dari database berdasarkan filter dengan cara yang aman
|
| 64 |
+
untuk mencegah SQL Injection.
|
| 65 |
+
"""
|
| 66 |
+
# Gunakan text() dan parameter binding (:nama_param) untuk keamanan
|
| 67 |
+
query = text("""
|
| 68 |
+
SELECT * FROM detail_penyakit
|
| 69 |
+
WHERE kode_pusk = :pusk AND tahun = :thn
|
| 70 |
+
""")
|
| 71 |
+
|
| 72 |
+
# Koneksi dibuka dan ditutup secara otomatis dengan 'with'
|
| 73 |
+
with engine.connect() as conn:
|
| 74 |
+
df = pd.read_sql(query, conn, params={"pusk": puskesmas, "thn": tahun})
|
| 75 |
+
|
| 76 |
+
# Logika kategori tetap sama
|
| 77 |
+
def tentukan_kategori(jenis):
|
| 78 |
+
if not isinstance(jenis, str): return 'Tidak Menular'
|
| 79 |
+
menular_keywords = ['Infeksi', 'Demam', 'Diare', 'TBC', 'HIV']
|
| 80 |
+
return 'Menular' if any(keyword.lower() in jenis.lower() for keyword in menular_keywords) else 'Tidak Menular'
|
| 81 |
+
|
| 82 |
+
if not df.empty:
|
| 83 |
+
df['kategori'] = df['jenis_penyakit'].apply(tentukan_kategori)
|
| 84 |
+
|
| 85 |
+
return df
|
| 86 |
+
|
| 87 |
+
# ==============================================================================
|
| 88 |
+
# SCRIPT UNTUK SETUP AWAL (TIDAK PERLU DIJALANKAN LAGI SETELAH TABEL DIBUAT)
|
| 89 |
+
# ==============================================================================
|
| 90 |
+
# Kode di bawah ini HANYA untuk membuat tabel di awal jika kamu mau.
|
| 91 |
+
# Tapi karena kita sudah buat tabel di UI Supabase, blok ini sebenarnya
|
| 92 |
+
# tidak perlu dijalankan. Saya tetap sertakan sebagai referensi.
|
| 93 |
+
def create_tables_initial_setup():
|
| 94 |
+
print("PERINGATAN: Fungsi ini akan mencoba membuat tabel. Seharusnya tidak diperlukan jika tabel sudah dibuat di Supabase.")
|
| 95 |
+
# Definisi manual tabel (hanya untuk fungsi ini)
|
| 96 |
+
# ... (salin-tempel definisi Table(...) dari file SQLite-mu ke sini) ...
|
| 97 |
+
# ...
|
| 98 |
+
# Lalu panggil metadata.create_all(engine)
|
| 99 |
+
pass
|
| 100 |
+
|
| 101 |
+
# Jalankan 'python database.py' di terminal untuk memverifikasi koneksi
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
print("Memverifikasi koneksi dan refleksi tabel...")
|
| 104 |
+
try:
|
| 105 |
+
# 1. Verifikasi koneksi
|
| 106 |
+
with engine.connect() as connection:
|
| 107 |
+
print("=> Koneksi ke database Supabase BERHASIL!")
|
| 108 |
+
|
| 109 |
+
# 2. Verifikasi hasil refleksi tabel
|
| 110 |
+
# Cek apakah nama tabel ada di dalam kamus metadata.tables
|
| 111 |
+
tabel_ditemukan = {
|
| 112 |
+
'users': 'users' in metadata.tables,
|
| 113 |
+
'data_penyakit': 'data_penyakit' in metadata.tables,
|
| 114 |
+
'detail_penyakit': 'detail_penyakit' in metadata.tables
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
semua_ditemukan = all(tabel_ditemukan.values())
|
| 118 |
+
|
| 119 |
+
if semua_ditemukan:
|
| 120 |
+
print("=> Refleksi tabel BERHASIL: Semua tabel (users, data_penyakit, detail_penyakit) ditemukan.")
|
| 121 |
+
else:
|
| 122 |
+
print("=> PERINGATAN: Tidak semua tabel ditemukan!")
|
| 123 |
+
for nama_tabel, status in tabel_ditemukan.items():
|
| 124 |
+
print(f" - Tabel '{nama_tabel}': {'Ditemukan' if status else 'TIDAK DITEMUKAN'}")
|
| 125 |
+
print(" Pastikan nama tabel sudah benar sesuai di database Supabase.")
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
print(f"Verifikasi GAGAL: {e}")
|
pages/__init__.py
ADDED
|
File without changes
|
pages/analisis_tren_penyakit.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# File: pages/analisis_tren_penyakit.py (Revisi Final dengan Perbaikan Parser PDF)
|
| 2 |
+
|
| 3 |
+
from dash import dcc, html, Input, Output, callback, no_update, State, dash_table
|
| 4 |
+
import dash_bootstrap_components as dbc
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import plotly.express as px
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
from sqlalchemy import select, distinct, func, and_
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
# Impor engine Anda dari file database.py
|
| 12 |
+
from database import engine, detail_penyakit
|
| 13 |
+
|
| 14 |
+
# Impor library untuk pembuatan PDF, dengan fallback jika tidak terinstall
|
| 15 |
+
try:
|
| 16 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, PageBreak, Table, TableStyle
|
| 17 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 18 |
+
from reportlab.lib.units import inch
|
| 19 |
+
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
| 20 |
+
from reportlab.lib import colors as reportlab_colors
|
| 21 |
+
import plotly.io as pio
|
| 22 |
+
|
| 23 |
+
if hasattr(pio, 'kaleido'):
|
| 24 |
+
pio.kaleido.scope.mathjax = None
|
| 25 |
+
PDF_CAPABLE = True
|
| 26 |
+
except (AttributeError, ImportError):
|
| 27 |
+
PDF_CAPABLE = False
|
| 28 |
+
print("WARNING: Pustaka 'reportlab' atau 'kaleido' tidak terinstall. Fitur unduh PDF tidak akan berfungsi.")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# -----------------------------------------------------------------------------
|
| 32 |
+
# BAGIAN 1: FUNGSI-FUNGSI HELPER (Tidak ada perubahan)
|
| 33 |
+
# -----------------------------------------------------------------------------
|
| 34 |
+
def kategori_penyakit_atp(icd):
|
| 35 |
+
if pd.isna(icd) or str(icd).strip() == "": return 'Tidak Menular'
|
| 36 |
+
icd_clean = str(icd).strip().upper()
|
| 37 |
+
if icd_clean.startswith(('A', 'B')): return 'Menular'
|
| 38 |
+
return 'Tidak Menular'
|
| 39 |
+
|
| 40 |
+
def get_filter_text(pusk, tahun, bulan):
|
| 41 |
+
pusk_txt = "Seluruh Puskesmas" if not pusk else ", ".join(pusk)
|
| 42 |
+
tahun_txt = "Seluruh Tahun" if not tahun else ", ".join(map(str, sorted(tahun)))
|
| 43 |
+
bulan_txt = "Seluruh Bulan" if not bulan else ", ".join(sorted(bulan))
|
| 44 |
+
return f"Menampilkan data dari: {pusk_txt} | Tahun: {tahun_txt} | Bulan: {bulan_txt}"
|
| 45 |
+
|
| 46 |
+
def create_ranking_analysis_text(df):
|
| 47 |
+
if df.empty: return "Tidak ada data untuk dianalisis."
|
| 48 |
+
top_disease = df.iloc[-1]
|
| 49 |
+
bottom_disease = df.iloc[0]
|
| 50 |
+
return dcc.Markdown(f"""
|
| 51 |
+
- Penyakit dengan kasus tertinggi adalah **{top_disease['jenis_penyakit']}** dengan total **{int(top_disease['totall']):,}** kasus.
|
| 52 |
+
- Penyakit peringkat ke-10 dalam daftar ini adalah **{bottom_disease['jenis_penyakit']}** dengan **{int(bottom_disease['totall']):,}** kasus.
|
| 53 |
+
""")
|
| 54 |
+
|
| 55 |
+
def create_ranking_table(df):
|
| 56 |
+
if df.empty: return None
|
| 57 |
+
df_display = df.copy()
|
| 58 |
+
df_display['sort_val'] = pd.to_numeric(df_display['totall'])
|
| 59 |
+
df_display = df_display.sort_values('sort_val', ascending=False).drop(columns=['sort_val'])
|
| 60 |
+
df_display['totall'] = df_display['totall'].apply(lambda x: f"{int(x):,}")
|
| 61 |
+
df_display = df_display.rename(columns={'jenis_penyakit': 'Jenis Penyakit', 'totall': 'Total Kasus'})
|
| 62 |
+
return dash_table.DataTable(
|
| 63 |
+
data=df_display.to_dict('records'),
|
| 64 |
+
columns=[{"name": i, "id": i} for i in df_display.columns],
|
| 65 |
+
style_table={'overflowX': 'auto', 'marginTop': '15px', 'border': '1px solid #ddd'},
|
| 66 |
+
style_cell={'textAlign': 'left', 'padding': '8px', 'fontFamily': 'sans-serif'},
|
| 67 |
+
style_header={'fontWeight': 'bold', 'backgroundColor': 'rgb(230, 230, 230)'},
|
| 68 |
+
style_data_conditional=[{'if': {'row_index': 'odd'}, 'backgroundColor': 'rgb(248, 248, 248)'}]
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
def create_pie_analysis_text(df):
|
| 72 |
+
if df.empty: return "Tidak ada data untuk dianalisis."
|
| 73 |
+
total_cases = df['totall'].sum()
|
| 74 |
+
if total_cases == 0: return "Total kasus adalah nol, tidak ada persentase untuk dihitung."
|
| 75 |
+
df['persentase'] = (df['totall'] / total_cases * 100).round(1)
|
| 76 |
+
menular = df[df['kategori'] == 'Menular']; tidak_menular = df[df['kategori'] == 'Tidak Menular']
|
| 77 |
+
perc_menular = menular['persentase'].iloc[0] if not menular.empty else 0
|
| 78 |
+
perc_tidak_menular = tidak_menular['persentase'].iloc[0] if not tidak_menular.empty else 0
|
| 79 |
+
kesimpulan = "dominan" if perc_menular > perc_tidak_menular else "lebih sedikit dibandingkan"
|
| 80 |
+
return dcc.Markdown(f"""- Dari total kasus yang ada, **{perc_menular}%** merupakan penyakit **Menular**, sementara **{perc_tidak_menular}%** adalah penyakit **Tidak Menular**.\n- Kasus penyakit Menular **{kesimpulan}** kasus penyakit Tidak Menular pada periode ini.""")
|
| 81 |
+
|
| 82 |
+
def create_trend_analysis_text(df, time_unit='tahun'):
|
| 83 |
+
if df.empty: return dcc.Markdown("Data tidak cukup untuk analisis tren.")
|
| 84 |
+
if time_unit == 'tahun' and df['tahun'].nunique() < 2: return dcc.Markdown("Analisis tren membutuhkan data dari minimal 2 tahun.")
|
| 85 |
+
top_3_diseases = df.groupby('jenis_penyakit')['totall'].sum().nlargest(3).index.tolist()
|
| 86 |
+
if not top_3_diseases: return dcc.Markdown("Tidak ada data penyakit untuk dianalisis trennya.")
|
| 87 |
+
analysis_points = []
|
| 88 |
+
for disease in top_3_diseases:
|
| 89 |
+
df_disease = df[df['jenis_penyakit'] == disease].sort_values(time_unit)
|
| 90 |
+
if len(df_disease) > 1:
|
| 91 |
+
start_val, end_val = df_disease['totall'].iloc[0], df_disease['totall'].iloc[-1]
|
| 92 |
+
if end_val > start_val: tren = f"mengalami **kenaikan** dari {int(start_val):,} menjadi {int(end_val):,}"
|
| 93 |
+
elif end_val < start_val: tren = f"mengalami **penurunan** dari {int(start_val):,} menjadi {int(end_val):,}"
|
| 94 |
+
else: tren = f"**stabil** di angka {int(end_val):,}"
|
| 95 |
+
analysis_points.append(f"- Kasus **{disease}** {tren} sepanjang periode.")
|
| 96 |
+
else: analysis_points.append(f"- Kasus **{disease}** hanya memiliki data untuk satu periode, tren tidak dapat dihitung.")
|
| 97 |
+
return dcc.Markdown("Ringkasan Tren untuk 3 Penyakit Teratas:\n\n" + "\n".join(analysis_points))
|
| 98 |
+
|
| 99 |
+
def create_category_trend_analysis_text(df):
|
| 100 |
+
if df.empty or len(df['periode'].unique()) < 2: return dcc.Markdown("Data tidak cukup untuk analisis tren kategori.")
|
| 101 |
+
analysis_points = []
|
| 102 |
+
for category in ['Menular', 'Tidak Menular']:
|
| 103 |
+
df_cat = df[df['kategori'] == category].sort_values('periode')
|
| 104 |
+
if not df_cat.empty and len(df_cat) > 1:
|
| 105 |
+
start_val, end_val = df_cat['totall'].iloc[0], df_cat['totall'].iloc[-1]
|
| 106 |
+
if end_val > start_val: tren = f"cenderung **naik** dari {int(start_val):,} menjadi {int(end_val):,}"
|
| 107 |
+
elif end_val < start_val: tren = f"cenderung **turun** dari {int(start_val):,} menjadi {int(end_val):,}"
|
| 108 |
+
else: tren = f"**stabil** di sekitar angka {int(end_val):,}"
|
| 109 |
+
analysis_points.append(f"- Tren kasus **{category}** {tren} selama periode ini.")
|
| 110 |
+
if not analysis_points: return dcc.Markdown("Tidak dapat menganalisis tren kategori.")
|
| 111 |
+
return dcc.Markdown("\n".join(analysis_points))
|
| 112 |
+
|
| 113 |
+
def create_comparison_analysis_text(df):
|
| 114 |
+
if df.empty: return "Tidak ada data untuk dianalisis."
|
| 115 |
+
pusk_contribution = df.groupby('kode_pusk')['totall'].sum().sort_values(ascending=False)
|
| 116 |
+
if pusk_contribution.empty: return "Tidak ada data puskesmas untuk dianalisis."
|
| 117 |
+
top_pusk, top_pusk_cases, total_cases = pusk_contribution.index[0], pusk_contribution.iloc[0], pusk_contribution.sum()
|
| 118 |
+
if total_cases == 0: return "Total kasus adalah nol."
|
| 119 |
+
top_pusk_percent = (top_pusk_cases / total_cases * 100).round(1)
|
| 120 |
+
top_disease_by_pusk = df[df['kode_pusk'] == top_pusk].groupby('jenis_penyakit')['totall'].sum().idxmax()
|
| 121 |
+
return dcc.Markdown(f"""- **{top_pusk}** menjadi puskesmas dengan kontribusi kasus tertinggi, yaitu **{int(top_pusk_cases):,}** kasus ({top_pusk_percent}% dari total).\n- Penyakit yang paling banyak disumbangkan oleh {top_pusk} adalah **{top_disease_by_pusk}**.""")
|
| 122 |
+
|
| 123 |
+
def create_yearly_bar_analysis_text(df):
|
| 124 |
+
df_copy = df.copy(); df_copy['tahun'] = pd.to_numeric(df_copy['tahun'])
|
| 125 |
+
if df_copy.empty or 'tahun' not in df_copy.columns or df_copy['tahun'].nunique() < 2: return dcc.Markdown("Data tidak cukup untuk membandingkan tren antar tahun.")
|
| 126 |
+
df_pivot = df_copy.pivot(index='jenis_penyakit', columns='tahun', values='totall').fillna(0); df_pivot['perubahan'] = df_pivot.iloc[:, -1] - df_pivot.iloc[:, 0]
|
| 127 |
+
penyakit_naik_terbesar, kenaikan = df_pivot['perubahan'].idxmax(), df_pivot['perubahan'].max()
|
| 128 |
+
penyakit_turun_terbesar, penurunan = df_pivot['perubahan'].idxmin(), df_pivot['perubahan'].min()
|
| 129 |
+
analysis_points = []
|
| 130 |
+
if kenaikan > 0: analysis_points.append(f"- Penyakit dengan **pertumbuhan kasus terbesar** adalah **{penyakit_naik_terbesar}**, bertambah **{int(kenaikan):,}** kasus.")
|
| 131 |
+
if penurunan < 0: analysis_points.append(f"- Penyakit dengan **penurunan paling signifikan** adalah **{penyakit_turun_terbesar}**, berkurang **{int(abs(penurunan)):,}** kasus.")
|
| 132 |
+
top_disease_last_year = df_copy[df_copy['tahun'] == df_copy['tahun'].max()].nlargest(1, 'totall')['jenis_penyakit'].iloc[0]
|
| 133 |
+
analysis_points.append(f"- Pada tahun terakhir ({int(df_copy['tahun'].max())}), **{top_disease_last_year}** menjadi penyakit dengan kasus terbanyak di antara 10 penyakit ini.")
|
| 134 |
+
if not analysis_points: return dcc.Markdown("Tidak dapat menghasilkan analisis tren dari data yang ada.")
|
| 135 |
+
return dcc.Markdown("\n".join(analysis_points))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# -----------------------------------------------------------------------------
|
| 139 |
+
# BAGIAN 2: LAYOUT HALAMAN (Tidak ada perubahan)
|
| 140 |
+
# -----------------------------------------------------------------------------
|
| 141 |
+
layout = dbc.Container([
|
| 142 |
+
dcc.Store(id='atp-data-store'),
|
| 143 |
+
dcc.Download(id="atp-download-pdf"),
|
| 144 |
+
dbc.Row([
|
| 145 |
+
dbc.Col(html.H3("Analisis Penyakit Komprehensif", className="mt-4 mb-4"), md=9),
|
| 146 |
+
dbc.Col(dbc.Button("Unduh Laporan (PDF)", id="atp-btn-unduh-pdf", color="primary", className="mt-4 float-end", disabled=not PDF_CAPABLE), md=3)
|
| 147 |
+
], align="center"),
|
| 148 |
+
dbc.Card(dbc.CardBody([dbc.Row([
|
| 149 |
+
dbc.Col([dbc.Label("Pilih Puskesmas:"), dcc.Dropdown(id='atp-pusk-filter', multi=True, placeholder="Pilih...")], md=4),
|
| 150 |
+
dbc.Col([dbc.Label("Pilih Tahun:"), dcc.Dropdown(id='atp-tahun-filter', multi=True, placeholder="Pilih...")], md=4),
|
| 151 |
+
dbc.Col([dbc.Label("Pilih Bulan:"), dcc.Dropdown(id='atp-bulan-filter', multi=True, placeholder="Pilih Tahun dulu...", disabled=True)], md=4)
|
| 152 |
+
])]), className="mb-3 shadow-sm"),
|
| 153 |
+
html.Div(id='atp-filter-summary-text', className="text-center text-muted fst-italic mb-3"),
|
| 154 |
+
dcc.Loading(id="atp-loading-main", type="dot", children=[
|
| 155 |
+
dbc.Tabs(id="atp-tabs", active_tab='tab-ranking', children=[
|
| 156 |
+
dbc.Tab(label="Ringkasan Peringkat", tab_id="tab-ranking", children=html.Div(id='tab-content-ranking')),
|
| 157 |
+
dbc.Tab(label="Analisis Tren", tab_id="tab-trend", children=html.Div(id='tab-content-trend')),
|
| 158 |
+
dbc.Tab(label="Perbandingan Puskesmas", tab_id="tab-comparison", children=html.Div(id='tab-content-comparison'))
|
| 159 |
+
])
|
| 160 |
+
]),
|
| 161 |
+
], fluid=True)
|
| 162 |
+
|
| 163 |
+
# -----------------------------------------------------------------------------
|
| 164 |
+
# BAGIAN 3: CALLBACKS (Tidak ada perubahan di luar callback utama dan PDF)
|
| 165 |
+
# -----------------------------------------------------------------------------
|
| 166 |
+
@callback(
|
| 167 |
+
Output('atp-pusk-filter', 'options'),
|
| 168 |
+
Output('atp-tahun-filter', 'options'),
|
| 169 |
+
Input('url', 'pathname')
|
| 170 |
+
)
|
| 171 |
+
def atp_load_filters(pathname):
|
| 172 |
+
if pathname != '/analisis_tren_penyakit': return no_update, no_update
|
| 173 |
+
try:
|
| 174 |
+
with engine.connect() as conn:
|
| 175 |
+
pusk_options = [{'label': p[0], 'value': p[0]} for p in conn.execute(select(distinct(detail_penyakit.c.kode_pusk)).order_by(detail_penyakit.c.kode_pusk)).fetchall() if p[0]]
|
| 176 |
+
tahun_options = [{'label': str(t[0]), 'value': t[0]} for t in conn.execute(select(distinct(detail_penyakit.c.tahun)).order_by(detail_penyakit.c.tahun.desc())).fetchall() if t[0]]
|
| 177 |
+
return pusk_options, tahun_options
|
| 178 |
+
except Exception as e: print(f"Error saat memuat filter: {e}"); return [], []
|
| 179 |
+
|
| 180 |
+
@callback(
|
| 181 |
+
Output('atp-bulan-filter', 'options'),
|
| 182 |
+
Output('atp-bulan-filter', 'disabled'),
|
| 183 |
+
Output('atp-bulan-filter', 'value'),
|
| 184 |
+
Input('atp-tahun-filter', 'value')
|
| 185 |
+
)
|
| 186 |
+
def update_bulan_options(selected_tahun):
|
| 187 |
+
if not selected_tahun: return [], True, []
|
| 188 |
+
nama_bulan = {'01':'Januari', '02':'Februari', '03':'Maret', '04':'April', '05':'Mei', '06':'Juni', '07':'Juli', '08':'Agustus', '09':'September', '10':'Oktober', '11':'November', '12':'Desember'}
|
| 189 |
+
try:
|
| 190 |
+
with engine.connect() as conn:
|
| 191 |
+
stmt = select(distinct(detail_penyakit.c.bulan)).where(detail_penyakit.c.tahun.in_(selected_tahun)).order_by(detail_penyakit.c.bulan)
|
| 192 |
+
bulan_list = [row[0] for row in conn.execute(stmt).fetchall() if row[0]]
|
| 193 |
+
bulan_options = [{'label': nama_bulan.get(b, b), 'value': b} for b in bulan_list]
|
| 194 |
+
return bulan_options, False, []
|
| 195 |
+
except Exception as e: print(f"Error saat memuat filter bulan: {e}"); return [], True, []
|
| 196 |
+
|
| 197 |
+
### CALLBACK UTAMA ###
|
| 198 |
+
@callback(
|
| 199 |
+
Output('tab-content-ranking', 'children'),
|
| 200 |
+
Output('tab-content-trend', 'children'),
|
| 201 |
+
Output('tab-content-comparison', 'children'),
|
| 202 |
+
Output('atp-filter-summary-text', 'children'),
|
| 203 |
+
Output('atp-btn-unduh-pdf', 'disabled'),
|
| 204 |
+
Output('atp-data-store', 'data'),
|
| 205 |
+
Input('atp-pusk-filter', 'value'),
|
| 206 |
+
Input('atp-tahun-filter', 'value'),
|
| 207 |
+
Input('atp-bulan-filter', 'value')
|
| 208 |
+
)
|
| 209 |
+
def update_all_content_based_on_filters(selected_pusk, selected_tahun, selected_bulan):
|
| 210 |
+
if not selected_pusk or not selected_tahun:
|
| 211 |
+
msg = html.P("Silakan lengkapi semua filter untuk memulai analisis.", className="text-center text-primary mt-5")
|
| 212 |
+
return msg, msg, msg, "", True, None
|
| 213 |
+
|
| 214 |
+
filter_summary_text = get_filter_text(selected_pusk, selected_tahun, selected_bulan)
|
| 215 |
+
filters = [detail_penyakit.c.kode_pusk.in_(selected_pusk), detail_penyakit.c.tahun.in_(selected_tahun)]
|
| 216 |
+
if selected_bulan: filters.append(detail_penyakit.c.bulan.in_(selected_bulan))
|
| 217 |
+
|
| 218 |
+
stmt = select(detail_penyakit.c.jenis_penyakit, detail_penyakit.c.icd_x, detail_penyakit.c.tahun, detail_penyakit.c.bulan, detail_penyakit.c.kode_pusk, func.sum(detail_penyakit.c.totall).label('totall')).where(and_(*filters)).group_by(detail_penyakit.c.jenis_penyakit, detail_penyakit.c.icd_x, detail_penyakit.c.tahun, detail_penyakit.c.bulan, detail_penyakit.c.kode_pusk)
|
| 219 |
+
with engine.connect() as conn: df_base = pd.read_sql(stmt, conn)
|
| 220 |
+
|
| 221 |
+
if df_base.empty:
|
| 222 |
+
msg = dbc.Alert("Tidak ada data ditemukan untuk kriteria yang dipilih.", color="warning", className="m-4")
|
| 223 |
+
return msg, msg, msg, filter_summary_text, True, None
|
| 224 |
+
|
| 225 |
+
kode_dihindari = ('V', 'W', 'X', 'Y', 'Z')
|
| 226 |
+
df_base['icd_x_str'] = df_base['icd_x'].astype(str).str.strip().str.upper()
|
| 227 |
+
df_filtered_icd = df_base[~df_base['icd_x_str'].str.startswith(kode_dihindari, na=False)].copy()
|
| 228 |
+
|
| 229 |
+
df_filtered_icd['kategori'] = df_filtered_icd['icd_x'].apply(kategori_penyakit_atp)
|
| 230 |
+
df_ranking_total = df_filtered_icd.groupby('jenis_penyakit')['totall'].sum().nlargest(10).sort_values().reset_index()
|
| 231 |
+
top_10_penyakit_list = df_ranking_total['jenis_penyakit'].tolist()
|
| 232 |
+
df_top10_base = df_filtered_icd[df_filtered_icd['jenis_penyakit'].isin(top_10_penyakit_list)]
|
| 233 |
+
|
| 234 |
+
# --- KONTEN TAB 1 ---
|
| 235 |
+
fig_ranking_simple = px.bar(df_ranking_total, x='totall', y='jenis_penyakit', orientation='h', template='plotly_white', title='<b>Peringkat 10 Penyakit Teratas (Keseluruhan)</b>', labels={'totall': 'Total Kasus', 'jenis_penyakit': ''})
|
| 236 |
+
analysis_ranking = create_ranking_analysis_text(df_ranking_total)
|
| 237 |
+
table_ranking = create_ranking_table(df_ranking_total)
|
| 238 |
+
df_category_pie = df_filtered_icd.groupby('kategori')['totall'].sum().reset_index()
|
| 239 |
+
fig_category_pie = px.pie(df_category_pie, values='totall', names='kategori', title='<b>Proporsi Kasus Penyakit Menular dan Tidak Menular (%)</b>', color_discrete_map={'Menular':'crimson', 'Tidak Menular':'royalblue'})
|
| 240 |
+
analysis_pie = create_pie_analysis_text(df_category_pie)
|
| 241 |
+
df_yearly_data = df_top10_base.groupby(['tahun', 'jenis_penyakit'])['totall'].sum().reset_index()
|
| 242 |
+
df_yearly_data['tahun'] = df_yearly_data['tahun'].astype(str)
|
| 243 |
+
fig_bar_trend_yearly = px.bar(df_yearly_data, x='totall', y='jenis_penyakit', color='tahun', orientation='h', barmode='group', title="<b>Perbandingan Kasus Tahunan untuk 10 Penyakit Teratas</b>", labels={'totall': 'Total Kasus', 'jenis_penyakit': '', 'tahun': 'Tahun'}, template='plotly_white')
|
| 244 |
+
fig_bar_trend_yearly.update_layout(yaxis={'categoryorder':'total ascending'})
|
| 245 |
+
analysis_yearly_bar = create_yearly_bar_analysis_text(df_yearly_data)
|
| 246 |
+
df_menular = df_filtered_icd[df_filtered_icd['kategori'] == 'Menular']; top_10_menular = df_menular.groupby('jenis_penyakit')['totall'].sum().nlargest(10).index
|
| 247 |
+
df_trend_menular = df_menular[df_menular['jenis_penyakit'].isin(top_10_menular)].groupby(['tahun', 'jenis_penyakit', 'icd_x'])['totall'].sum().reset_index(); df_trend_menular['tahun'] = df_trend_menular['tahun'].astype(str)
|
| 248 |
+
fig_trend_menular = px.bar(df_trend_menular, x='totall', y='jenis_penyakit', color='tahun', orientation='h', barmode='group', title="<b>Perbandingan Tahunan 10 Penyakit Menular Teratas</b>", labels={'jenis_penyakit': '', 'totall': 'Total Kasus', 'tahun': 'Tahun'}, template='plotly_white', hover_data={'icd_x': True}); fig_trend_menular.update_layout(yaxis={'categoryorder':'total ascending'})
|
| 249 |
+
analysis_menular = create_trend_analysis_text(df_trend_menular, 'tahun')
|
| 250 |
+
df_tidak_menular = df_filtered_icd[df_filtered_icd['kategori'] == 'Tidak Menular']; top_10_tidak_menular = df_tidak_menular.groupby('jenis_penyakit')['totall'].sum().nlargest(10).index
|
| 251 |
+
df_trend_tidak_menular = df_tidak_menular[df_tidak_menular['jenis_penyakit'].isin(top_10_tidak_menular)].groupby(['tahun', 'jenis_penyakit', 'icd_x'])['totall'].sum().reset_index(); df_trend_tidak_menular['tahun'] = df_trend_tidak_menular['tahun'].astype(str)
|
| 252 |
+
fig_trend_tidak_menular = px.bar(df_trend_tidak_menular, x='totall', y='jenis_penyakit', color='tahun', orientation='h', barmode='group', title="<b>Perbandingan Tahunan 10 Penyakit Tidak Menular Teratas</b>", labels={'jenis_penyakit': '', 'totall': 'Total Kasus', 'tahun': 'Tahun'}, template='plotly_white', hover_data={'icd_x': True}); fig_trend_tidak_menular.update_layout(yaxis={'categoryorder':'total ascending'})
|
| 253 |
+
analysis_tidak_menular = create_trend_analysis_text(df_trend_tidak_menular, 'tahun')
|
| 254 |
+
|
| 255 |
+
tab1_content = html.Div([
|
| 256 |
+
dbc.Row(dbc.Col([
|
| 257 |
+
dcc.Graph(figure=fig_ranking_simple),
|
| 258 |
+
html.H5("Tabel Peringkat 10 Besar", className="mt-4"),
|
| 259 |
+
table_ranking,
|
| 260 |
+
dbc.Card(dbc.CardBody(analysis_ranking), className="mt-3 mb-4")
|
| 261 |
+
])),
|
| 262 |
+
dbc.Row(dbc.Col([dcc.Graph(figure=fig_bar_trend_yearly), dbc.Card(dbc.CardBody(analysis_yearly_bar), className="mt-2 mb-4")])),
|
| 263 |
+
dbc.Row(dbc.Col([dcc.Graph(figure=fig_category_pie), dbc.Card(dbc.CardBody(analysis_pie), className="mt-2 mb-4")])),
|
| 264 |
+
html.Hr(className="my-5"),
|
| 265 |
+
html.H4("Analisis Detail Berdasarkan Kategori Penyakit", className="text-center mb-4"),
|
| 266 |
+
dbc.Row(dbc.Col([dcc.Graph(figure=fig_trend_menular), dbc.Card(dbc.CardBody(analysis_menular), className="mt-2 mb-4")])),
|
| 267 |
+
dbc.Row(dbc.Col([dcc.Graph(figure=fig_trend_tidak_menular), dbc.Card(dbc.CardBody(analysis_tidak_menular), className="mt-2 mb-4")]))
|
| 268 |
+
])
|
| 269 |
+
|
| 270 |
+
# --- KONTEN TAB 2 ---
|
| 271 |
+
df_yearly_data_for_line = df_top10_base.groupby(['tahun', 'jenis_penyakit'])['totall'].sum().reset_index(); fig_line_trend_yearly = px.line(df_yearly_data_for_line, x='tahun', y='totall', color='jenis_penyakit', markers=True, title="<b>Tren Tahunan 10 Penyakit Teratas (Keseluruhan)</b>"); analysis_yearly_trend = create_trend_analysis_text(df_yearly_data_for_line, 'tahun')
|
| 272 |
+
df_line_data_monthly = df_top10_base.groupby(['tahun', 'bulan', 'jenis_penyakit'])['totall'].sum().reset_index(); df_line_data_monthly['periode'] = df_line_data_monthly['tahun'].astype(str) + '-' + df_line_data_monthly['bulan'].str.zfill(2); df_line_data_monthly.sort_values('periode', inplace=True); fig_line_trend_monthly = px.line(df_line_data_monthly, x='periode', y='totall', color='jenis_penyakit', markers=False, title="<b>Tren Bulanan 10 Penyakit Teratas</b>"); analysis_monthly_trend = create_trend_analysis_text(df_line_data_monthly, 'periode')
|
| 273 |
+
df_monthly_cat_trend = df_filtered_icd.groupby(['tahun', 'bulan', 'kategori'])['totall'].sum().reset_index(); df_monthly_cat_trend['periode'] = df_monthly_cat_trend['tahun'].astype(str) + '-' + df_monthly_cat_trend['bulan'].str.zfill(2); df_monthly_cat_trend.sort_values('periode', inplace=True); fig_monthly_compare_trend = px.area(df_monthly_cat_trend, x='periode', y='totall', color='kategori', title="<b>Perbandingan Tren Bulanan Kasus Menular dan Tidak Menular</b>", color_discrete_map={'Menular':'crimson', 'Tidak Menular':'royalblue'}); analysis_category_trend = create_category_trend_analysis_text(df_monthly_cat_trend)
|
| 274 |
+
tab2_content = html.Div([dbc.Row(dbc.Col([dcc.Graph(figure=fig_line_trend_yearly), dbc.Card(dbc.CardBody(analysis_yearly_trend), className="mt-2 mb-4")])), dbc.Row(dbc.Col([dcc.Graph(figure=fig_line_trend_monthly), dbc.Card(dbc.CardBody(analysis_monthly_trend), className="mt-2 mb-4")])), dbc.Row(dbc.Col([dcc.Graph(figure=fig_monthly_compare_trend), dbc.Card(dbc.CardBody(analysis_category_trend), className="mt-2")]))])
|
| 275 |
+
|
| 276 |
+
# --- KONTEN TAB 3 ---
|
| 277 |
+
df_pusk_compare = df_top10_base.groupby(['kode_pusk', 'jenis_penyakit'])['totall'].sum().reset_index(); fig_pusk_stacked = px.bar(df_pusk_compare, x='totall', y='jenis_penyakit', color='kode_pusk', orientation='h', template='plotly_white', title='<b>Kontribusi Kasus per Puskesmas</b>', labels={'totall': 'Total Kasus', 'jenis_penyakit': '', 'kode_pusk': 'Puskesmas'}); analysis_pusk_comparison = create_comparison_analysis_text(df_pusk_compare)
|
| 278 |
+
tab3_content = html.Div([dbc.Row(dbc.Col([dcc.Graph(figure=fig_pusk_stacked), dbc.Card(dbc.CardBody(analysis_pusk_comparison), className="mt-2")]))])
|
| 279 |
+
|
| 280 |
+
data_to_store = {
|
| 281 |
+
'figs_json': {
|
| 282 |
+
'ranking_simple': fig_ranking_simple.to_json(), 'category_pie': fig_category_pie.to_json(),
|
| 283 |
+
'bar_trend_yearly': fig_bar_trend_yearly.to_json(), 'trend_menular': fig_trend_menular.to_json(),
|
| 284 |
+
'trend_tidak_menular': fig_trend_tidak_menular.to_json(), 'line_trend_yearly': fig_line_trend_yearly.to_json(),
|
| 285 |
+
'line_trend_monthly': fig_line_trend_monthly.to_json(), 'monthly_compare_trend': fig_monthly_compare_trend.to_json(),
|
| 286 |
+
'pusk_stacked': fig_pusk_stacked.to_json()
|
| 287 |
+
},
|
| 288 |
+
'analysis_texts': {
|
| 289 |
+
'ranking': analysis_ranking.children, 'pie': analysis_pie.children,
|
| 290 |
+
'yearly_bar': analysis_yearly_bar.children, 'menular': analysis_menular.children,
|
| 291 |
+
'tidak_menular': analysis_tidak_menular.children, 'yearly_trend': analysis_yearly_trend.children,
|
| 292 |
+
'monthly_trend': analysis_monthly_trend.children, 'category_trend': analysis_category_trend.children,
|
| 293 |
+
'pusk_comparison': analysis_pusk_comparison.children
|
| 294 |
+
},
|
| 295 |
+
'table_data': {
|
| 296 |
+
'ranking': df_ranking_total.to_dict('records')
|
| 297 |
+
},
|
| 298 |
+
'filter_text': filter_summary_text
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
return tab1_content, tab2_content, tab3_content, filter_summary_text, not PDF_CAPABLE, data_to_store
|
| 302 |
+
|
| 303 |
+
### CALLBACK PDF ###
|
| 304 |
+
@callback(
|
| 305 |
+
Output("atp-download-pdf", "data"),
|
| 306 |
+
Input("atp-btn-unduh-pdf", "n_clicks"),
|
| 307 |
+
State("atp-data-store", "data"),
|
| 308 |
+
prevent_initial_call=True
|
| 309 |
+
)
|
| 310 |
+
def download_report_as_pdf(n_clicks, stored_data):
|
| 311 |
+
if not n_clicks or not stored_data or not PDF_CAPABLE:
|
| 312 |
+
return no_update
|
| 313 |
+
|
| 314 |
+
buffer = io.BytesIO()
|
| 315 |
+
doc = SimpleDocTemplate(buffer, pagesize=(8.5*inch, 11*inch), rightMargin=0.5*inch, leftMargin=0.5*inch, topMargin=0.5*inch, bottomMargin=0.5*inch)
|
| 316 |
+
|
| 317 |
+
styles = getSampleStyleSheet()
|
| 318 |
+
style_h1 = ParagraphStyle(name='H1', parent=styles['h1'], alignment=TA_CENTER, fontSize=16, spaceAfter=14)
|
| 319 |
+
style_h2 = ParagraphStyle(name='H2', parent=styles['h2'], alignment=TA_LEFT, fontSize=14, spaceBefore=12, spaceAfter=6, textColor=reportlab_colors.HexColor("#1A3A69"))
|
| 320 |
+
style_h3 = ParagraphStyle(name='H3', parent=styles['h3'], alignment=TA_LEFT, fontSize=11, spaceBefore=10, spaceAfter=4, textColor=reportlab_colors.HexColor("#444444"))
|
| 321 |
+
style_body = styles['BodyText']
|
| 322 |
+
style_body.leading = 14
|
| 323 |
+
|
| 324 |
+
def fig_to_image(fig_json):
|
| 325 |
+
if not fig_json: return Spacer(1, 0.1*inch)
|
| 326 |
+
try:
|
| 327 |
+
fig = go.Figure(pio.from_json(fig_json))
|
| 328 |
+
fig.update_layout(margin=dict(l=20, r=20, t=50, b=20), title_x=0.5)
|
| 329 |
+
img_bytes = pio.to_image(fig, format="png", width=800, height=450, scale=2)
|
| 330 |
+
return Image(io.BytesIO(img_bytes), width=7*inch, height=(7*450/800)*inch)
|
| 331 |
+
except Exception as e:
|
| 332 |
+
print(f"Error saat membuat gambar PDF: {e}")
|
| 333 |
+
return Paragraph("<i>Gagal memuat gambar.</i>", style_body)
|
| 334 |
+
|
| 335 |
+
# ==============================================================================
|
| 336 |
+
# <<< PERBAIKAN UTAMA ADA DI FUNGSI INI >>>
|
| 337 |
+
# ==============================================================================
|
| 338 |
+
def text_to_paragraph(text_markdown):
|
| 339 |
+
if not isinstance(text_markdown, str):
|
| 340 |
+
return Paragraph("Analisis tidak tersedia.", style_body)
|
| 341 |
+
|
| 342 |
+
# Ganti newline dulu
|
| 343 |
+
text_html = text_markdown.replace('\n', '<br/>')
|
| 344 |
+
|
| 345 |
+
# Ganti markdown bold (**) dengan tag <b>...</b> secara berpasangan
|
| 346 |
+
parts = text_html.split('**')
|
| 347 |
+
for i in range(1, len(parts), 2):
|
| 348 |
+
parts[i] = f"<b>{parts[i]}</b>"
|
| 349 |
+
|
| 350 |
+
final_text = "".join(parts)
|
| 351 |
+
|
| 352 |
+
return Paragraph(final_text, style_body)
|
| 353 |
+
# ==============================================================================
|
| 354 |
+
|
| 355 |
+
def create_pdf_table(table_data_records):
|
| 356 |
+
if not table_data_records: return Spacer(1, 0.1*inch)
|
| 357 |
+
|
| 358 |
+
headers = ['Jenis Penyakit', 'Total Kasus'] # Header manual agar urutan benar
|
| 359 |
+
|
| 360 |
+
data = [headers]
|
| 361 |
+
for row in table_data_records:
|
| 362 |
+
# Format angka dengan koma
|
| 363 |
+
row_data = [
|
| 364 |
+
row.get('jenis_penyakit', ''),
|
| 365 |
+
f"{int(row.get('totall', 0)):,}"
|
| 366 |
+
]
|
| 367 |
+
data.append(row_data)
|
| 368 |
+
|
| 369 |
+
table = Table(data, colWidths=[5.5*inch, 1.5*inch])
|
| 370 |
+
|
| 371 |
+
style = TableStyle([
|
| 372 |
+
('BACKGROUND', (0,0), (-1,0), reportlab_colors.HexColor("#4682B4")),
|
| 373 |
+
('TEXTCOLOR',(0,0),(-1,0), reportlab_colors.whitesmoke),
|
| 374 |
+
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
| 375 |
+
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
| 376 |
+
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
|
| 377 |
+
('BOTTOMPADDING', (0,0), (-1,0), 12),
|
| 378 |
+
('BACKGROUND', (0,1), (-1,-1), reportlab_colors.HexColor("#F0F8FF")),
|
| 379 |
+
('GRID', (0,0), (-1,-1), 1, reportlab_colors.black),
|
| 380 |
+
('ROWBACKGROUNDS', (0,1), (-1,-1), [reportlab_colors.HexColor("#F0F8FF"), reportlab_colors.white])
|
| 381 |
+
])
|
| 382 |
+
table.setStyle(style)
|
| 383 |
+
return table
|
| 384 |
+
|
| 385 |
+
story = [
|
| 386 |
+
Paragraph("Laporan Analisis Tren Penyakit", style_h1),
|
| 387 |
+
Paragraph(stored_data.get('filter_text', ''), styles['Italic']),
|
| 388 |
+
Spacer(1, 0.3*inch)
|
| 389 |
+
]
|
| 390 |
+
|
| 391 |
+
analysis = stored_data.get('analysis_texts', {})
|
| 392 |
+
figs = stored_data.get('figs_json', {})
|
| 393 |
+
tables = stored_data.get('table_data', {})
|
| 394 |
+
|
| 395 |
+
# --- HALAMAN 1: RINGKASAN PERINGKAT ---
|
| 396 |
+
story.append(Paragraph("1. Ringkasan Peringkat", style_h2))
|
| 397 |
+
story.append(fig_to_image(figs.get('ranking_simple')))
|
| 398 |
+
story.append(Spacer(1, 0.2*inch))
|
| 399 |
+
|
| 400 |
+
story.append(Paragraph("Tabel Peringkat 10 Besar", style_h3))
|
| 401 |
+
story.append(create_pdf_table(tables.get('ranking')))
|
| 402 |
+
story.append(Spacer(1, 0.2*inch))
|
| 403 |
+
|
| 404 |
+
story.append(text_to_paragraph(analysis.get('ranking', 'Analisis tidak tersedia.')))
|
| 405 |
+
story.append(PageBreak())
|
| 406 |
+
|
| 407 |
+
story.append(fig_to_image(figs.get('bar_trend_yearly')))
|
| 408 |
+
story.append(Spacer(1, 0.1*inch))
|
| 409 |
+
story.append(text_to_paragraph(analysis.get('yearly_bar', 'Analisis tidak tersedia.')))
|
| 410 |
+
story.append(PageBreak())
|
| 411 |
+
|
| 412 |
+
# --- HALAMAN 2: DETAIL KATEGORI ---
|
| 413 |
+
story.append(Paragraph("2. Analisis Berdasarkan Kategori", style_h2))
|
| 414 |
+
story.append(fig_to_image(figs.get('category_pie')))
|
| 415 |
+
story.append(Spacer(1, 0.1*inch))
|
| 416 |
+
story.append(text_to_paragraph(analysis.get('pie', 'Analisis tidak tersedia.')))
|
| 417 |
+
story.append(Spacer(1, 0.3*inch))
|
| 418 |
+
|
| 419 |
+
story.append(Paragraph("Tren 10 Penyakit Menular Teratas", style_h3))
|
| 420 |
+
story.append(fig_to_image(figs.get('trend_menular')))
|
| 421 |
+
story.append(Spacer(1, 0.1*inch))
|
| 422 |
+
story.append(text_to_paragraph(analysis.get('menular', 'Analisis tidak tersedia.')))
|
| 423 |
+
story.append(PageBreak())
|
| 424 |
+
|
| 425 |
+
story.append(Paragraph("Tren 10 Penyakit Tidak Menular Teratas", style_h3))
|
| 426 |
+
story.append(fig_to_image(figs.get('trend_tidak_menular')))
|
| 427 |
+
story.append(Spacer(1, 0.1*inch))
|
| 428 |
+
story.append(text_to_paragraph(analysis.get('tidak_menular', 'Analisis tidak tersedia.')))
|
| 429 |
+
story.append(PageBreak())
|
| 430 |
+
|
| 431 |
+
# --- HALAMAN 3: ANALISIS TREN ---
|
| 432 |
+
story.append(Paragraph("3. Analisis Tren", style_h2))
|
| 433 |
+
story.append(fig_to_image(figs.get('line_trend_yearly')))
|
| 434 |
+
story.append(Spacer(1, 0.1*inch))
|
| 435 |
+
story.append(text_to_paragraph(analysis.get('yearly_trend', 'Analisis tidak tersedia.')))
|
| 436 |
+
story.append(Spacer(1, 0.3*inch))
|
| 437 |
+
|
| 438 |
+
story.append(fig_to_image(figs.get('line_trend_monthly')))
|
| 439 |
+
story.append(Spacer(1, 0.1*inch))
|
| 440 |
+
story.append(text_to_paragraph(analysis.get('monthly_trend', 'Analisis tidak tersedia.')))
|
| 441 |
+
story.append(PageBreak())
|
| 442 |
+
|
| 443 |
+
# --- HALAMAN 4: PERBANDINGAN PUSKESMAS ---
|
| 444 |
+
story.append(Paragraph("4. Perbandingan Antar Puskesmas", style_h2))
|
| 445 |
+
story.append(fig_to_image(figs.get('pusk_stacked')))
|
| 446 |
+
story.append(Spacer(1, 0.1*inch))
|
| 447 |
+
story.append(text_to_paragraph(analysis.get('pusk_comparison', 'Analisis tidak tersedia.')))
|
| 448 |
+
|
| 449 |
+
doc.build(story)
|
| 450 |
+
return dcc.send_bytes(buffer.getvalue(), "Laporan_Analisis_Penyakit_Revisi.pdf")
|
pages/beranda.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# File: pages/beranda.py (Revisi Final - Ringkasan Tahunan Otomatis)
|
| 2 |
+
|
| 3 |
+
from dash import html, dcc, Input, Output, callback, no_update
|
| 4 |
+
import dash_bootstrap_components as dbc
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from sqlalchemy import select, func, and_
|
| 7 |
+
import plotly.express as px
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
|
| 10 |
+
# Impor engine Anda
|
| 11 |
+
from database import engine, detail_penyakit
|
| 12 |
+
|
| 13 |
+
# -----------------------------------------------------------------------------
|
| 14 |
+
# LAYOUT HALAMAN (Tidak ada perubahan signifikan, hanya menunggu data)
|
| 15 |
+
# -----------------------------------------------------------------------------
|
| 16 |
+
layout = dbc.Container([
|
| 17 |
+
html.H3("Beranda: Ringkasan Eksekutif", className="mt-4 mb-4 text-center"),
|
| 18 |
+
html.H5(id='beranda-summary-title', className="text-center text-muted mb-4"),
|
| 19 |
+
|
| 20 |
+
# Kartu Ringkasan
|
| 21 |
+
dbc.Row([
|
| 22 |
+
dbc.Col(dbc.Card([dbc.CardHeader(html.H5("Penyakit Tertinggi", className="card-title mb-0")), dbc.CardBody([html.H2("-", id='kasus-tertinggi-auto', className="card-text")])], color="primary", inverse=True, className="h-100"), md=3, className="mb-3"),
|
| 23 |
+
dbc.Col(dbc.Card([dbc.CardHeader(html.H5("Menular Terbanyak", className="card-title mb-0")), dbc.CardBody([html.H2("-", id='menular-terbanyak-auto', className="card-text")])], color="danger", inverse=True, className="h-100"), md=3, className="mb-3"),
|
| 24 |
+
dbc.Col(dbc.Card([dbc.CardHeader(html.H5("Tidak Menular Terbanyak", className="card-title mb-0")), dbc.CardBody([html.H2("-", id='tidak-menular-terbanyak-auto', className="card-text")])], color="success", inverse=True, className="h-100"), md=3, className="mb-3"),
|
| 25 |
+
dbc.Col(dbc.Card([dbc.CardHeader(html.H5("Laki-laki vs Perempuan", className="card-title mb-0")), dbc.CardBody([html.H2("-", id='jumlah-pasien-auto', className="card-text")])], color="info", inverse=True, className="h-100"), md=3, className="mb-3"),
|
| 26 |
+
], className="mb-4"),
|
| 27 |
+
|
| 28 |
+
html.Hr(),
|
| 29 |
+
|
| 30 |
+
dcc.Loading(id="beranda-loading-graphs", type="dot", children=[
|
| 31 |
+
dbc.Row([
|
| 32 |
+
dbc.Col(dcc.Graph(id='beranda-graph-ranking'), md=8),
|
| 33 |
+
dbc.Col(dcc.Graph(id='beranda-graph-gender'), md=4),
|
| 34 |
+
], className="mt-4"),
|
| 35 |
+
dbc.Row([
|
| 36 |
+
dbc.Col(dcc.Graph(id='beranda-graph-puskesmas'), width=12)
|
| 37 |
+
], className="mt-4")
|
| 38 |
+
])
|
| 39 |
+
], fluid=True)
|
| 40 |
+
|
| 41 |
+
# -----------------------------------------------------------------------------
|
| 42 |
+
# CALLBACK UTAMA (Logika diubah untuk mengambil data tahunan)
|
| 43 |
+
# -----------------------------------------------------------------------------
|
| 44 |
+
@callback(
|
| 45 |
+
# Output untuk Kartu
|
| 46 |
+
Output('beranda-summary-title', 'children'),
|
| 47 |
+
Output('kasus-tertinggi-auto', 'children'),
|
| 48 |
+
Output('menular-terbanyak-auto', 'children'),
|
| 49 |
+
Output('tidak-menular-terbanyak-auto', 'children'),
|
| 50 |
+
Output('jumlah-pasien-auto', 'children'),
|
| 51 |
+
# Output untuk Grafik
|
| 52 |
+
Output('beranda-graph-ranking', 'figure'),
|
| 53 |
+
Output('beranda-graph-gender', 'figure'),
|
| 54 |
+
Output('beranda-graph-puskesmas', 'figure'),
|
| 55 |
+
Input('url', 'pathname')
|
| 56 |
+
)
|
| 57 |
+
def update_beranda_dashboard(pathname):
|
| 58 |
+
if pathname != '/beranda':
|
| 59 |
+
return (no_update,) * 8
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
with engine.connect() as conn:
|
| 63 |
+
# Langkah 1: Cari TAHUN terbaru di database
|
| 64 |
+
latest_year_query = select(func.max(detail_penyakit.c.tahun))
|
| 65 |
+
latest_tahun = conn.execute(latest_year_query).scalar_one_or_none()
|
| 66 |
+
|
| 67 |
+
if not latest_tahun:
|
| 68 |
+
no_data_msg = "Tidak Ada Data"; empty_fig = go.Figure()
|
| 69 |
+
return "Tidak ada data di database", no_data_msg, no_data_msg, no_data_msg, no_data_msg, empty_fig, empty_fig, empty_fig
|
| 70 |
+
|
| 71 |
+
# Langkah 2: Ambil data ringkasan dari SEMUA PUSKESMAS & SEMUA BULAN untuk TAHUN TERBARU
|
| 72 |
+
summary_query = select(
|
| 73 |
+
detail_penyakit.c.jenis_penyakit, detail_penyakit.c.icd_x, detail_penyakit.c.kode_pusk,
|
| 74 |
+
func.sum(detail_penyakit.c.totall).label('total_kasus'),
|
| 75 |
+
func.sum(detail_penyakit.c.laki_laki).label('total_laki'),
|
| 76 |
+
func.sum(detail_penyakit.c.perempuan).label('total_perempuan')
|
| 77 |
+
).where(
|
| 78 |
+
detail_penyakit.c.tahun == latest_tahun # <-- FILTER HANYA BERDASARKAN TAHUN
|
| 79 |
+
).group_by(
|
| 80 |
+
detail_penyakit.c.jenis_penyakit, detail_penyakit.c.icd_x, detail_penyakit.c.kode_pusk
|
| 81 |
+
)
|
| 82 |
+
df_summary = pd.read_sql(summary_query, conn)
|
| 83 |
+
|
| 84 |
+
# Langkah 3: Proses data & buat Teks untuk Kartu
|
| 85 |
+
if df_summary.empty:
|
| 86 |
+
no_data_msg = "Tidak Ada Data"; empty_fig = go.Figure()
|
| 87 |
+
return f"Tidak ada data untuk Tahun {latest_tahun}", no_data_msg, no_data_msg, no_data_msg, "0 ♂ | 0 ♀", empty_fig, empty_fig, empty_fig
|
| 88 |
+
|
| 89 |
+
# Fungsi helper untuk kategorisasi
|
| 90 |
+
def kategori_penyakit(icd):
|
| 91 |
+
if pd.isna(icd): return 'Tidak Diketahui'
|
| 92 |
+
return 'Menular' if str(icd).strip().upper().startswith(('A', 'B')) else 'Tidak Menular'
|
| 93 |
+
df_summary['kategori'] = df_summary['icd_x'].apply(kategori_penyakit)
|
| 94 |
+
|
| 95 |
+
# Agregasi data untuk kartu
|
| 96 |
+
df_penyakit_grouped = df_summary.groupby('jenis_penyakit')['total_kasus'].sum().reset_index()
|
| 97 |
+
tertinggi = df_penyakit_grouped.loc[df_penyakit_grouped['total_kasus'].idxmax()]
|
| 98 |
+
kasus_tertinggi_text = f"{tertinggi['jenis_penyakit']} ({int(tertinggi['total_kasus']):,})"
|
| 99 |
+
|
| 100 |
+
menular_df = df_summary[df_summary['kategori'] == 'Menular'].groupby('jenis_penyakit')['total_kasus'].sum()
|
| 101 |
+
menular_text = "N/A"
|
| 102 |
+
if not menular_df.empty:
|
| 103 |
+
top_menular = menular_df.idxmax()
|
| 104 |
+
menular_text = f"{top_menular} ({int(menular_df.sum()):,})"
|
| 105 |
+
|
| 106 |
+
tidak_menular_df = df_summary[df_summary['kategori'] == 'Tidak Menular'].groupby('jenis_penyakit')['total_kasus'].sum()
|
| 107 |
+
tidak_menular_text = "N/A"
|
| 108 |
+
if not tidak_menular_df.empty:
|
| 109 |
+
top_tidak_menular = tidak_menular_df.idxmax()
|
| 110 |
+
tidak_menular_text = f"{top_tidak_menular} ({int(tidak_menular_df.sum()):,})"
|
| 111 |
+
|
| 112 |
+
total_laki = int(df_summary['total_laki'].sum()); total_perempuan = int(df_summary['total_perempuan'].sum())
|
| 113 |
+
pasien_text = f"{total_laki:,} ♂ | {total_perempuan:,} ♀"
|
| 114 |
+
|
| 115 |
+
title_text = f"Data Gabungan Seluruh Puskesmas untuk Tahun {latest_tahun}"
|
| 116 |
+
|
| 117 |
+
# --- Langkah 4: Buat Grafik-Grafik Kunci ---
|
| 118 |
+
# Grafik 1: Peringkat Penyakit Tahunan
|
| 119 |
+
df_ranking = df_penyakit_grouped.nlargest(10, 'total_kasus').sort_values('total_kasus')
|
| 120 |
+
fig_ranking = px.bar(df_ranking, x='total_kasus', y='jenis_penyakit', orientation='h', template='plotly_white',
|
| 121 |
+
title=f'<b>Peringkat 10 Penyakit Teratas - Tahun {latest_tahun}</b>', labels={'total_kasus': 'Total Kasus', 'jenis_penyakit': ''})
|
| 122 |
+
|
| 123 |
+
# Grafik 2: Distribusi Gender Tahunan
|
| 124 |
+
fig_gender = go.Figure(data=[go.Pie(labels=['Laki-laki', 'Perempuan'], values=[total_laki, total_perempuan], hole=.4, marker_colors=['royalblue', 'crimson'])])
|
| 125 |
+
fig_gender.update_layout(title_text='<b>Distribusi Gender (Tahunan)</b>', template='plotly_white', margin=dict(t=50, b=20, l=20, r=20))
|
| 126 |
+
|
| 127 |
+
# Grafik 3: Perbandingan Puskesmas Tahunan
|
| 128 |
+
df_pusk_compare = df_summary.groupby('kode_pusk')['total_kasus'].sum().sort_values(ascending=False).reset_index()
|
| 129 |
+
fig_puskesmas = px.bar(df_pusk_compare, x='kode_pusk', y='total_kasus', template='plotly_white',
|
| 130 |
+
title=f'<b>Total Kasus per Puskesmas - Tahun {latest_tahun}</b>', labels={'kode_pusk': 'Puskesmas', 'total_kasus': 'Jumlah Kasus'})
|
| 131 |
+
|
| 132 |
+
return title_text, kasus_tertinggi_text, menular_text, tidak_menular_text, pasien_text, fig_ranking, fig_gender, fig_puskesmas
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
import traceback; traceback.print_exc()
|
| 136 |
+
error_msg, empty_fig = "Terjadi Error", go.Figure()
|
| 137 |
+
return f"Gagal memuat data: {e}", error_msg, error_msg, error_msg, error_msg, empty_fig, empty_fig, empty_fig
|
pages/distribusi_kasus_demografi.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# File: pages/distribusi_kasus_demografi.py (Revisi Final dengan Bulan Dinamis)
|
| 2 |
+
|
| 3 |
+
from dash import dcc, html, Input, Output, callback, no_update, State
|
| 4 |
+
import dash_bootstrap_components as dbc
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import plotly.graph_objects as go
|
| 7 |
+
import plotly.express as px
|
| 8 |
+
from sqlalchemy import select, distinct, func, and_
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
# Impor library yang dibutuhkan untuk PDF
|
| 12 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, PageBreak
|
| 13 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 14 |
+
from reportlab.lib.units import inch
|
| 15 |
+
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
|
| 16 |
+
import plotly.io as pio
|
| 17 |
+
|
| 18 |
+
# Impor engine Anda dari file database.py
|
| 19 |
+
from database import engine, detail_penyakit
|
| 20 |
+
|
| 21 |
+
# -----------------------------------------------------------------------------
|
| 22 |
+
# PENGATURAN AWAL
|
| 23 |
+
# -----------------------------------------------------------------------------
|
| 24 |
+
try:
|
| 25 |
+
# Pengaturan agar unduh PDF tidak error
|
| 26 |
+
pio.kaleido.scope.mathjax = None
|
| 27 |
+
except (AttributeError, ImportError):
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
# -----------------------------------------------------------------------------
|
| 31 |
+
# FUNGSI HELPER (ANALISIS TEKS)
|
| 32 |
+
# -----------------------------------------------------------------------------
|
| 33 |
+
def get_filter_text(pusk, tahun, bulan):
|
| 34 |
+
pusk_txt = "Seluruh Puskesmas" if not pusk else ", ".join(pusk)
|
| 35 |
+
tahun_txt = "Seluruh Tahun" if not tahun else ", ".join(map(str, sorted(tahun)))
|
| 36 |
+
bulan_txt = "Seluruh Bulan" if not bulan else ", ".join(sorted(bulan))
|
| 37 |
+
return f"Menampilkan data dari: {pusk_txt} | Tahun: {tahun_txt} | Bulan: {bulan_txt}"
|
| 38 |
+
|
| 39 |
+
def generate_gender_analysis_text(total_lk, total_pr):
|
| 40 |
+
if total_lk == 0 and total_pr == 0: return "Tidak ada data kasus untuk dianalisis."
|
| 41 |
+
total_kasus = total_lk + total_pr
|
| 42 |
+
persen_lk = (total_lk / total_kasus * 100) if total_kasus > 0 else 0
|
| 43 |
+
persen_pr = (total_pr / total_kasus * 100) if total_kasus > 0 else 0
|
| 44 |
+
kesimpulan = ""
|
| 45 |
+
if persen_lk > persen_pr:
|
| 46 |
+
kesimpulan = f"lebih banyak menyerang laki-laki ({persen_lk:.1f}%) dibandingkan perempuan ({persen_pr:.1f}%)"
|
| 47 |
+
elif persen_pr > persen_lk:
|
| 48 |
+
kesimpulan = f"lebih banyak menyerang perempuan ({persen_pr:.1f}%) dibandingkan laki-laki ({persen_lk:.1f}%)"
|
| 49 |
+
else:
|
| 50 |
+
kesimpulan = f"memiliki distribusi yang seimbang antara laki-laki dan perempuan"
|
| 51 |
+
return f"Total kasus tercatat: **{int(total_kasus):,}**. Secara umum, kasus penyakit {kesimpulan}."
|
| 52 |
+
|
| 53 |
+
def generate_age_analysis_text(baru_data, lama_data):
|
| 54 |
+
total_kasus_umur_baru = sum(baru_data.values())
|
| 55 |
+
total_kasus_umur_lama = sum(lama_data.values())
|
| 56 |
+
total_kasus_umur = total_kasus_umur_baru + total_kasus_umur_lama
|
| 57 |
+
if total_kasus_umur == 0: return "Tidak ada data kasus untuk dianalisis."
|
| 58 |
+
|
| 59 |
+
# Hitung total kasus per kelompok umur
|
| 60 |
+
total_per_kelompok = {k: baru_data.get(k, 0) + lama_data.get(k, 0) for k in set(baru_data) | set(lama_data)}
|
| 61 |
+
|
| 62 |
+
max_total_kelompok = max(total_per_kelompok, key=total_per_kelompok.get, default=None)
|
| 63 |
+
max_baru_kelompok = max(baru_data, key=baru_data.get, default=None) if total_kasus_umur_baru > 0 else "Tidak ada"
|
| 64 |
+
|
| 65 |
+
return f"Kelompok umur dengan total kasus tertinggi adalah **{max_total_kelompok}<**. Kasus baru terbanyak ditemukan pada kelompok **{max_baru_kelompok}**."
|
| 66 |
+
|
| 67 |
+
# -----------------------------------------------------------------------------
|
| 68 |
+
# LAYOUT HALAMAN
|
| 69 |
+
# -----------------------------------------------------------------------------
|
| 70 |
+
layout = dbc.Container([
|
| 71 |
+
dcc.Store(id='dkd-data-store'),
|
| 72 |
+
dcc.Download(id="dkd-download-pdf"),
|
| 73 |
+
|
| 74 |
+
# Header dan Tombol Unduh
|
| 75 |
+
dbc.Row([
|
| 76 |
+
dbc.Col(html.H3("Distribusi Kasus Demografi", className="mt-4 mb-4"), md=9),
|
| 77 |
+
dbc.Col(dbc.Button("Unduh Halaman (PDF)", id="dkd-btn-download", color="primary", className="mt-4 float-end", disabled=True), md=3)
|
| 78 |
+
], align="center"),
|
| 79 |
+
|
| 80 |
+
# Panel Filter Utama
|
| 81 |
+
dbc.Card(dbc.CardBody([
|
| 82 |
+
dbc.Row([
|
| 83 |
+
dbc.Col([dbc.Label("Pilih Puskesmas:"), dcc.Dropdown(id='dkd-pusk-filter', multi=True, placeholder="Pilih...")], md=4),
|
| 84 |
+
dbc.Col([dbc.Label("Pilih Tahun:"), dcc.Dropdown(id='dkd-tahun-filter', multi=True, placeholder="Pilih...")], md=4),
|
| 85 |
+
dbc.Col([
|
| 86 |
+
dbc.Label("Pilih Bulan:"),
|
| 87 |
+
dcc.Dropdown(id='dkd-bulan-filter', multi=True, placeholder="Pilih Tahun dulu...", disabled=True)
|
| 88 |
+
], md=4)
|
| 89 |
+
])
|
| 90 |
+
]), className="mb-3 shadow-sm"),
|
| 91 |
+
|
| 92 |
+
html.Div(id='dkd-filter-summary-text', className="text-center text-muted fst-italic mb-3"),
|
| 93 |
+
|
| 94 |
+
dcc.Loading(id="dkd-loading-main", type="dot", children=[
|
| 95 |
+
dbc.Tabs(id="dkd-tabs", active_tab='tab-gender', children=[
|
| 96 |
+
dbc.Tab(label="Distribusi Jenis Kelamin", tab_id="tab-gender", children=html.Div(id='tab-content-gender')),
|
| 97 |
+
dbc.Tab(label="Distribusi Kelompok Umur", tab_id="tab-age", children=html.Div(id='tab-content-age')),
|
| 98 |
+
])
|
| 99 |
+
])
|
| 100 |
+
], fluid=True)
|
| 101 |
+
|
| 102 |
+
# -----------------------------------------------------------------------------
|
| 103 |
+
# CALLBACKS
|
| 104 |
+
# -----------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
# Callback 1: Memuat filter Puskesmas dan Tahun saat halaman dibuka
|
| 107 |
+
@callback(
|
| 108 |
+
Output('dkd-pusk-filter', 'options'),
|
| 109 |
+
Output('dkd-tahun-filter', 'options'),
|
| 110 |
+
Input('url', 'pathname')
|
| 111 |
+
)
|
| 112 |
+
def dkd_load_main_filters(pathname):
|
| 113 |
+
if pathname != '/distribusi_kasus_demografi':
|
| 114 |
+
return no_update, no_update
|
| 115 |
+
try:
|
| 116 |
+
with engine.connect() as conn:
|
| 117 |
+
pusk_list = [row[0] for row in conn.execute(select(distinct(detail_penyakit.c.kode_pusk)).where(detail_penyakit.c.kode_pusk.is_not(None), detail_penyakit.c.kode_pusk != '').order_by(detail_penyakit.c.kode_pusk)).fetchall() if row[0]]
|
| 118 |
+
tahun_list = [row[0] for row in conn.execute(select(distinct(detail_penyakit.c.tahun)).where(detail_penyakit.c.tahun.is_not(None)).order_by(detail_penyakit.c.tahun.desc())).fetchall() if row[0]]
|
| 119 |
+
pusk_options = [{'label': p, 'value': p} for p in pusk_list]
|
| 120 |
+
tahun_options = [{'label': str(t), 'value': t} for t in tahun_list]
|
| 121 |
+
return pusk_options, tahun_options
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"Error saat memuat filter demografi: {e}")
|
| 124 |
+
return [], []
|
| 125 |
+
|
| 126 |
+
# Callback 2: Memperbarui filter Bulan berdasarkan Tahun yang dipilih (CHAINED CALLBACK)
|
| 127 |
+
@callback(
|
| 128 |
+
Output('dkd-bulan-filter', 'options'),
|
| 129 |
+
Output('dkd-bulan-filter', 'disabled'),
|
| 130 |
+
Output('dkd-bulan-filter', 'value'), # Otomatis reset value bulan jika tahun berubah
|
| 131 |
+
Input('dkd-tahun-filter', 'value')
|
| 132 |
+
)
|
| 133 |
+
def dkd_update_bulan_filter(selected_tahun):
|
| 134 |
+
if not selected_tahun:
|
| 135 |
+
return [], True, []
|
| 136 |
+
|
| 137 |
+
nama_bulan = {
|
| 138 |
+
'01': 'Januari', '02': 'Februari', '03': 'Maret', '04': 'April',
|
| 139 |
+
'05': 'Mei', '06': 'Juni', '07': 'Juli', '08': 'Agustus',
|
| 140 |
+
'09': 'September', '10': 'Oktober', '11': 'November', '12': 'Desember'
|
| 141 |
+
}
|
| 142 |
+
try:
|
| 143 |
+
with engine.connect() as conn:
|
| 144 |
+
stmt = select(distinct(detail_penyakit.c.bulan)).where(detail_penyakit.c.tahun.in_(selected_tahun)).order_by(detail_penyakit.c.bulan)
|
| 145 |
+
bulan_list = [row[0] for row in conn.execute(stmt).fetchall() if row[0]]
|
| 146 |
+
|
| 147 |
+
bulan_options = [{'label': nama_bulan.get(b, b), 'value': b} for b in bulan_list]
|
| 148 |
+
return bulan_options, False, []
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
print(f"Error saat memuat filter bulan dinamis: {e}")
|
| 152 |
+
return [], True, []
|
| 153 |
+
|
| 154 |
+
# Callback 3: Memperbarui semua konten berdasarkan filter
|
| 155 |
+
@callback(
|
| 156 |
+
Output('tab-content-gender', 'children'),
|
| 157 |
+
Output('tab-content-age', 'children'),
|
| 158 |
+
Output('dkd-filter-summary-text', 'children'),
|
| 159 |
+
Output('dkd-data-store', 'data'),
|
| 160 |
+
Output('dkd-btn-download', 'disabled'),
|
| 161 |
+
Input('dkd-pusk-filter', 'value'),
|
| 162 |
+
Input('dkd-tahun-filter', 'value'),
|
| 163 |
+
Input('dkd-bulan-filter', 'value')
|
| 164 |
+
)
|
| 165 |
+
def update_demografi_tabs(selected_pusk, selected_tahun, selected_bulan):
|
| 166 |
+
if not all([selected_pusk, selected_tahun, selected_bulan]):
|
| 167 |
+
msg = html.P("Silakan pilih Puskesmas, Tahun, dan Bulan untuk memulai analisis.", className="text-center text-primary mt-5")
|
| 168 |
+
return msg, msg, "", None, True
|
| 169 |
+
|
| 170 |
+
filter_summary_text = get_filter_text(selected_pusk, selected_tahun, selected_bulan)
|
| 171 |
+
filters = [
|
| 172 |
+
detail_penyakit.c.kode_pusk.in_(selected_pusk),
|
| 173 |
+
detail_penyakit.c.tahun.in_(selected_tahun),
|
| 174 |
+
detail_penyakit.c.bulan.in_(selected_bulan)
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
usia_cols = ['laki_laki', 'perempuan', 'usia_0_7_hr_baru', 'usia_0_7_hr_lama', 'usia_8_28_hr_baru', 'usia_8_28_hr_lama', 'usia_1bl_1th_baru', 'usia_1bl_1th_lama', 'usia_1_4th_baru', 'usia_1_4th_lama', 'usia_5_9th_baru', 'usia_5_9th_lama', 'usia_10_14th_baru', 'usia_10_14th_lama', 'usia_15_19th_baru', 'usia_15_19th_lama', 'usia_20_44th_baru', 'usia_20_44th_lama', 'usia_45_54th_baru', 'usia_45_54th_lama', 'usia_55_59th_baru', 'usia_55_59th_lama', 'usia_60_69th_baru', 'usia_60_69th_lama', 'usia_70pl_baru', 'usia_70pl_lama']
|
| 178 |
+
stmt = select(*[func.sum(getattr(detail_penyakit.c, col)).label(col) for col in usia_cols]).where(and_(*filters))
|
| 179 |
+
|
| 180 |
+
with engine.connect() as conn:
|
| 181 |
+
result = conn.execute(stmt).fetchone()
|
| 182 |
+
|
| 183 |
+
if not result or all(val is None for val in result):
|
| 184 |
+
msg = dbc.Alert("Tidak ada data ditemukan untuk kriteria yang dipilih.", color="warning", className="m-4")
|
| 185 |
+
return msg, msg, filter_summary_text, None, True
|
| 186 |
+
|
| 187 |
+
# --- KONTEN TAB 1: JENIS KELAMIN ---
|
| 188 |
+
total_lk = result.laki_laki or 0
|
| 189 |
+
total_pr = result.perempuan or 0
|
| 190 |
+
fig_gender = go.Figure(data=[go.Pie(labels=['Laki-laki', 'Perempuan'], values=[total_lk, total_pr], hole=.4, marker_colors=['royalblue', 'crimson'])])
|
| 191 |
+
fig_gender.update_layout(title_text='</b>Distribusi Kasus Berdasarkan Jenis Kelamin</b>', template='plotly_white')
|
| 192 |
+
analysis_gender = generate_gender_analysis_text(total_lk, total_pr)
|
| 193 |
+
tab_gender_content = dbc.Row(dbc.Col([dcc.Graph(figure=fig_gender), dbc.Card(dbc.CardBody(dcc.Markdown(analysis_gender)), className="mt-2")]), className="mt-4")
|
| 194 |
+
|
| 195 |
+
# --- KONTEN TAB 2: KELOMPOK UMUR ---
|
| 196 |
+
kelompok_map = {'Bayi & Balita (<5th)':['usia_0_7_hr_baru','usia_0_7_hr_lama','usia_8_28_hr_baru','usia_8_28_hr_lama','usia_1bl_1th_baru','usia_1bl_1th_lama','usia_1_4th_baru','usia_1_4th_lama'],'Anak (5-9th)':['usia_5_9th_baru','usia_5_9th_lama'],'Remaja (10-19th)':['usia_10_14th_baru','usia_10_14th_lama','usia_15_19th_baru','usia_15_19th_lama'],'Dewasa (20-59th)':['usia_20_44th_baru','usia_20_44th_lama','usia_45_54th_baru','usia_45_54th_lama','usia_55_59th_baru','usia_55_59th_lama'],'Lansia (60+th)':['usia_60_69th_baru','usia_60_69th_lama','usia_70pl_baru','usia_70pl_lama']}
|
| 197 |
+
baru_data, lama_data = {}, {}
|
| 198 |
+
for kelompok, cols in kelompok_map.items():
|
| 199 |
+
baru_data[kelompok] = sum(getattr(result, col, 0) or 0 for col in cols if 'baru' in col)
|
| 200 |
+
lama_data[kelompok] = sum(getattr(result, col, 0) or 0 for col in cols if 'lama' in col)
|
| 201 |
+
|
| 202 |
+
fig_age = go.Figure(data=[
|
| 203 |
+
go.Bar(name='Kasus Baru', x=list(baru_data.keys()), y=list(baru_data.values())),
|
| 204 |
+
go.Bar(name='Kasus Lama', x=list(lama_data.keys()), y=list(lama_data.values()))
|
| 205 |
+
]).update_layout(barmode='group', title_text="</b>Distribusi Kasus Berdasarkan Kelompok Umur</b>", template='plotly_white')
|
| 206 |
+
analysis_age = generate_age_analysis_text(baru_data, lama_data)
|
| 207 |
+
tab_age_content = dbc.Row(dbc.Col([dcc.Graph(figure=fig_age), dbc.Card(dbc.CardBody(dcc.Markdown(analysis_age)), className="mt-2")]), className="mt-4")
|
| 208 |
+
|
| 209 |
+
data_to_store = {'gender': {'Laki-laki': total_lk, 'Perempuan': total_pr}, 'age_new': baru_data, 'age_old': lama_data}
|
| 210 |
+
|
| 211 |
+
return tab_gender_content, tab_age_content, filter_summary_text, data_to_store, False
|
| 212 |
+
|
| 213 |
+
# Callback 4: Menghasilkan dan Mengunduh PDF
|
| 214 |
+
@callback(
|
| 215 |
+
Output("dkd-download-pdf", "data"),
|
| 216 |
+
Input("dkd-btn-download", "n_clicks"),
|
| 217 |
+
State("dkd-data-store", "data"),
|
| 218 |
+
State("dkd-filter-summary-text", "children"),
|
| 219 |
+
prevent_initial_call=True
|
| 220 |
+
)
|
| 221 |
+
def generate_demografi_pdf(n_clicks, stored_data, filter_text):
|
| 222 |
+
if not stored_data:
|
| 223 |
+
return no_update
|
| 224 |
+
|
| 225 |
+
buffer = io.BytesIO()
|
| 226 |
+
doc = SimpleDocTemplate(buffer, pagesize=(8.5*inch, 11*inch), rightMargin=0.75*inch, leftMargin=0.75*inch, topMargin=0.75*inch, bottomMargin=0.75*inch)
|
| 227 |
+
styles = getSampleStyleSheet()
|
| 228 |
+
style_h1 = ParagraphStyle(name='H1', parent=styles['h1'], alignment=TA_CENTER, fontSize=16, spaceAfter=14)
|
| 229 |
+
style_h2 = ParagraphStyle(name='H2', parent=styles['h2'], alignment=TA_LEFT, fontSize=14, spaceBefore=12, spaceAfter=6, fontName='Helvetica-Bold')
|
| 230 |
+
style_analysis = ParagraphStyle(name='Analysis', parent=styles['Normal'], alignment=TA_JUSTIFY, fontSize=10, leading=14, spaceAfter=10)
|
| 231 |
+
story = []
|
| 232 |
+
|
| 233 |
+
story.append(Paragraph("Laporan Distribusi Kasus Demografi", style_h1))
|
| 234 |
+
story.append(Paragraph(filter_text, styles['Normal']))
|
| 235 |
+
story.append(Spacer(1, 0.3*inch))
|
| 236 |
+
|
| 237 |
+
# Membuat ulang grafik dan analisis dari data yang disimpan
|
| 238 |
+
gender_data = stored_data['gender']; total_lk, total_pr = gender_data['Laki-laki'], gender_data['Perempuan']
|
| 239 |
+
fig_gender = go.Figure(data=[go.Pie(labels=['Laki-laki', 'Perempuan'], values=[total_lk, total_pr], hole=.4, marker_colors=['royalblue', 'crimson'])])
|
| 240 |
+
fig_gender.update_layout(title_text='Distribusi Kasus Berdasarkan Jenis Kelamin', template='plotly_white')
|
| 241 |
+
analysis_gender = generate_gender_analysis_text(total_lk, total_pr)
|
| 242 |
+
|
| 243 |
+
baru_data, lama_data = stored_data['age_new'], stored_data['age_old']
|
| 244 |
+
fig_age = go.Figure(data=[go.Bar(name='Kasus Baru', x=list(baru_data.keys()), y=list(baru_data.values())), go.Bar(name='Kasus Lama', x=list(lama_data.keys()), y=list(lama_data.values()))]).update_layout(barmode='group', title_text="Distribusi Kasus Berdasarkan Kelompok Umur", template='plotly_white')
|
| 245 |
+
analysis_age = generate_age_analysis_text(baru_data, lama_data)
|
| 246 |
+
|
| 247 |
+
def add_content_to_story(fig, analysis_text):
|
| 248 |
+
try:
|
| 249 |
+
img_bytes = pio.to_image(fig, format="png", width=800, height=450, scale=2)
|
| 250 |
+
img = Image(io.BytesIO(img_bytes), width=7*inch, height=(7*450/800)*inch); img.hAlign = 'CENTER'
|
| 251 |
+
story.append(img)
|
| 252 |
+
story.append(Spacer(1, 0.1*inch))
|
| 253 |
+
story.append(Paragraph(analysis_text, style_analysis))
|
| 254 |
+
story.append(Spacer(1, 0.2*inch))
|
| 255 |
+
except Exception as e:
|
| 256 |
+
story.append(Paragraph(f"<i>Gagal memuat grafik: {e}</i>", styles['Italic']))
|
| 257 |
+
|
| 258 |
+
story.append(Paragraph("Distribusi Jenis Kelamin", style_h2)); add_content_to_story(fig_gender, analysis_gender); story.append(PageBreak())
|
| 259 |
+
story.append(Paragraph("Distribusi Kelompok Umur", style_h2)); add_content_to_story(fig_age, analysis_age)
|
| 260 |
+
|
| 261 |
+
doc.build(story)
|
| 262 |
+
buffer.seek(0)
|
| 263 |
+
return dcc.send_bytes(buffer.getvalue(), "Laporan_Demografi.pdf")
|
pages/input_data.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pages/input_data_penyakit.py (VERSI FINAL - MULTI-FILE, DETEKSI DUPLIKAT, HAPUS & GANTI)
|
| 2 |
+
from dash import dcc, html, dash_table, Input, Output, State, callback, ctx, no_update
|
| 3 |
+
import dash_bootstrap_components as dbc
|
| 4 |
+
import base64
|
| 5 |
+
import io
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import zipfile
|
| 8 |
+
from sqlalchemy import insert, delete, select, and_
|
| 9 |
+
import uuid
|
| 10 |
+
import json # Import json untuk dcc.Store
|
| 11 |
+
from database import engine, data_penyakit, detail_penyakit, users
|
| 12 |
+
|
| 13 |
+
# --- Kamus untuk Memetakan Nama Bulan ke Angka ---
|
| 14 |
+
MONTH_MAPPING = {
|
| 15 |
+
'januari': '01', 'jan': '01', 'februari': '02', 'feb': '02', 'maret': '03', 'mar': '03',
|
| 16 |
+
'april': '04', 'apr': '04', 'mei': '05', 'juni': '06', 'jun': '06', 'juli': '07', 'jul': '07',
|
| 17 |
+
'agustus': '08', 'ags': '08', 'agu': '08', 'september': '09', 'sep': '09', 'sept': '09',
|
| 18 |
+
'oktober': '10', 'okt': '10', 'november': '11', 'nov': '11', 'desember': '12', 'des': '12',
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# --- Kolom Mapping (Definisikan di luar callback) ---
|
| 22 |
+
KOLOM_MAPPING = {
|
| 23 |
+
'Tahun': 'tahun', 'Kode pusk': 'kode_pusk', 'ICD X': 'icd_x', 'Jenis Penyakit': 'jenis_penyakit',
|
| 24 |
+
'Totall': 'totall', 'Laki-Laki': 'laki_laki', 'Perempuan': 'perempuan', 'Kasus Baru': 'kasus_baru',
|
| 25 |
+
'Kasus Lama': 'kasus_lama', '0-7 hr (Baru)': 'usia_0_7_hr_baru', '0-7 hr (Lama)': 'usia_0_7_hr_lama',
|
| 26 |
+
'8-28 hr (Baru)': 'usia_8_28_hr_baru', '8-28 hr (Lama)': 'usia_8_28_hr_lama', '1bl-1 th (Baru)': 'usia_1bl_1th_baru',
|
| 27 |
+
'1bl-1 th (Lama)': 'usia_1bl_1th_lama', '1-4 th (Baru)': 'usia_1_4th_baru', '1-4 th (Lama)': 'usia_1_4th_lama',
|
| 28 |
+
'5-9 th (Baru)': 'usia_5_9th_baru', '5-9 th (Lama)': 'usia_5_9th_lama', '10-14 th (Baru)': 'usia_10_14th_baru',
|
| 29 |
+
'10-14 th (Lama)': 'usia_10_14th_lama', '15-19 th (Baru)': 'usia_15_19th_baru', '15-19 th (Lama)': 'usia_15_19th_lama',
|
| 30 |
+
'20-44 th (Baru)': 'usia_20_44th_baru', '20-44 th (Lama)': 'usia_20_44th_lama', '45-54 th (Baru)': 'usia_45_54th_baru',
|
| 31 |
+
'45-54 th (Lama)': 'usia_45_54th_lama', '55-59 th (Baru)': 'usia_55_59th_baru', '55-59 th (Lama)': 'usia_55_59th_lama',
|
| 32 |
+
'60-69 (Baru)': 'usia_60_69th_baru', '60-69 (Lama)': 'usia_60_69th_lama', '70+ (Baru)': 'usia_70pl_baru',
|
| 33 |
+
'70+ (Lama)': 'usia_70pl_lama',
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# --- Layout Halaman Input ---
|
| 37 |
+
layout = dbc.Container([
|
| 38 |
+
# Tempat penyimpanan data sementara di browser
|
| 39 |
+
dcc.Store(id='temp-file-storage'),
|
| 40 |
+
|
| 41 |
+
html.H3("Input Data Penyakit", className="mt-4 mb-4 text-center"),
|
| 42 |
+
dcc.Upload(
|
| 43 |
+
id='upload-data-multi',
|
| 44 |
+
children=html.Div(['Drag and Drop atau ', html.A('Pilih File Laporan (.zip, .xlsx, .xls)')]),
|
| 45 |
+
style={'width': '100%', 'height': '60px', 'lineHeight': '60px', 'borderWidth': '1px', 'borderStyle': 'dashed',
|
| 46 |
+
'borderRadius': '5px', 'textAlign': 'center', 'marginBottom': '20px'},
|
| 47 |
+
accept=".zip,.xlsx,.xls",
|
| 48 |
+
multiple=True
|
| 49 |
+
),
|
| 50 |
+
html.Div(id='file-list-preview', className="mb-3"),
|
| 51 |
+
dbc.Row([
|
| 52 |
+
dbc.Col(dbc.Button("Unggah ke Database", id='submit-button-multi', color="success", className="w-100 mb-2"), md=6),
|
| 53 |
+
dbc.Col(dbc.Button("Tampilkan Daftar Data Terunggah", id='show-uploaded-data', color="info", className="w-100"), md=6)
|
| 54 |
+
], justify="center", className="mb-3"),
|
| 55 |
+
|
| 56 |
+
# Tempat notifikasi dan tombol konfirmasi
|
| 57 |
+
html.Div(id='upload-alert-wrapper', className="mt-3", children=[
|
| 58 |
+
html.Div(id='upload-alert'),
|
| 59 |
+
dbc.Row([
|
| 60 |
+
dbc.Col(dbc.Button("Hapus & Ganti Data", id="replace-multi-button", color="danger", className="me-2", style={'display': 'none'}), width="auto"),
|
| 61 |
+
dbc.Col(dbc.Button("Batal", id="cancel-multi-button", color="secondary", style={'display': 'none'}), width="auto")
|
| 62 |
+
], justify="center", className="mt-2")
|
| 63 |
+
]),
|
| 64 |
+
|
| 65 |
+
html.Div(id='uploaded-data-table', className="mt-4")
|
| 66 |
+
], fluid=True)
|
| 67 |
+
|
| 68 |
+
# --- Fungsi Helper ---
|
| 69 |
+
def safe_int_convert(value, default=0):
|
| 70 |
+
if pd.isna(value) or str(value).strip() == '': return default
|
| 71 |
+
try: return int(float(value))
|
| 72 |
+
except (ValueError, TypeError): return default
|
| 73 |
+
|
| 74 |
+
def parse_file_and_get_metadata(contents, filename):
|
| 75 |
+
# ... (fungsi ini tidak berubah) ...
|
| 76 |
+
extracted_month = None
|
| 77 |
+
for month_name, month_number in MONTH_MAPPING.items():
|
| 78 |
+
if month_name in filename.lower():
|
| 79 |
+
extracted_month = month_number
|
| 80 |
+
break
|
| 81 |
+
if not extracted_month: raise ValueError(f"Bulan tidak dapat ditemukan dari nama file '{filename}'.")
|
| 82 |
+
content_type, content_string = contents.split(',')
|
| 83 |
+
decoded = base64.b64decode(content_string)
|
| 84 |
+
df = pd.read_excel(io.BytesIO(decoded), header=None)
|
| 85 |
+
if df.shape[0] < 3: raise ValueError("Format file Excel tidak sesuai: header diharapkan di baris ke-3.")
|
| 86 |
+
header_row = df.iloc[2].astype(str).str.strip()
|
| 87 |
+
df_cleaned = df.iloc[3:].copy()
|
| 88 |
+
df_cleaned.columns = header_row
|
| 89 |
+
df_cleaned.reset_index(drop=True, inplace=True)
|
| 90 |
+
if df_cleaned.empty: raise ValueError("Tidak ada baris data valid setelah header.")
|
| 91 |
+
tahun = safe_int_convert(df_cleaned['Tahun'].iloc[0])
|
| 92 |
+
puskesmas = str(df_cleaned['Kode pusk'].iloc[0]).strip()
|
| 93 |
+
if not tahun or not puskesmas: raise ValueError("Kolom 'Tahun' atau 'Kode pusk' di baris pertama data tidak valid.")
|
| 94 |
+
return df_cleaned, extracted_month, tahun, puskesmas
|
| 95 |
+
|
| 96 |
+
def insert_records(conn, file_data, user_id):
|
| 97 |
+
# ... Fungsi untuk melakukan insert, agar bisa dipanggil ulang ...
|
| 98 |
+
upload_session = str(uuid.uuid4())
|
| 99 |
+
df_json = file_data['df'].to_json(orient='records', date_format='iso')
|
| 100 |
+
|
| 101 |
+
stmt_master = insert(data_penyakit).values(
|
| 102 |
+
id_user=user_id, nama_file=file_data['filename'], bulan=file_data['month'],
|
| 103 |
+
tahun=file_data['year'], puskesmas=file_data['pusk'],
|
| 104 |
+
dataframe_csv=df_json, upload_session=upload_session
|
| 105 |
+
)
|
| 106 |
+
result_master = conn.execute(stmt_master)
|
| 107 |
+
master_id = result_master.inserted_primary_key[0]
|
| 108 |
+
|
| 109 |
+
records_detail = []
|
| 110 |
+
for _, row in file_data['df'].iterrows():
|
| 111 |
+
detail_record = {'id_data_penyakit': master_id, 'tahun': file_data['year'], 'bulan': file_data['month'], 'kode_pusk': file_data['pusk'], 'upload_session': upload_session}
|
| 112 |
+
for excel_col, db_col in KOLOM_MAPPING.items():
|
| 113 |
+
if excel_col not in ['Tahun', 'Kode pusk']:
|
| 114 |
+
detail_record[db_col] = safe_int_convert(row.get(excel_col)) if 'usia' in db_col or db_col in ['totall','laki_laki','perempuan','kasus_baru','kasus_lama'] else (str(row.get(excel_col)).strip() if pd.notna(row.get(excel_col)) else None)
|
| 115 |
+
records_detail.append(detail_record)
|
| 116 |
+
if records_detail: conn.execute(insert(detail_penyakit), records_detail)
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
# --- Callback untuk Preview Daftar File ---
|
| 120 |
+
@callback(Output('file-list-preview', 'children'), Input('upload-data-multi', 'filename'), prevent_initial_call=True)
|
| 121 |
+
def update_file_list(filenames):
|
| 122 |
+
if not filenames: return ""
|
| 123 |
+
return html.Div([html.H5("File yang akan diunggah:"), dbc.ListGroup([dbc.ListGroupItem(f) for f in filenames])])
|
| 124 |
+
|
| 125 |
+
# --- TAHAP 1: Cek Duplikat Saat Tombol "Unggah" Diklik ---
|
| 126 |
+
@callback(
|
| 127 |
+
Output('upload-alert', 'children'),
|
| 128 |
+
Output('replace-multi-button', 'style'),
|
| 129 |
+
Output('cancel-multi-button', 'style'),
|
| 130 |
+
Output('temp-file-storage', 'data'),
|
| 131 |
+
Input('submit-button-multi', 'n_clicks'),
|
| 132 |
+
State('upload-data-multi', 'contents'),
|
| 133 |
+
State('upload-data-multi', 'filename'),
|
| 134 |
+
State('login-status', 'data'),
|
| 135 |
+
prevent_initial_call=True
|
| 136 |
+
)
|
| 137 |
+
def check_duplicates_and_store(n_clicks, list_of_contents, list_of_filenames, login_data):
|
| 138 |
+
hidden, visible = {'display': 'none'}, {'display': 'inline-block'}
|
| 139 |
+
if not n_clicks or not list_of_contents: return no_update, hidden, hidden, None
|
| 140 |
+
|
| 141 |
+
user_id = login_data.get('id_user')
|
| 142 |
+
files_to_process, initial_errors = [], []
|
| 143 |
+
|
| 144 |
+
for content, filename in zip(list_of_contents, list_of_filenames):
|
| 145 |
+
try:
|
| 146 |
+
df, month, year, pusk = parse_file_and_get_metadata(content, filename)
|
| 147 |
+
files_to_process.append({
|
| 148 |
+
'df_json': df.to_json(orient='split'), # Simpan df sebagai json
|
| 149 |
+
'filename': filename, 'month': month, 'year': year, 'pusk': pusk
|
| 150 |
+
})
|
| 151 |
+
except Exception as e:
|
| 152 |
+
initial_errors.append(f"Gagal memproses '{filename}': {e}")
|
| 153 |
+
|
| 154 |
+
if initial_errors:
|
| 155 |
+
return dbc.Alert([html.P("Beberapa file gagal divalidasi:"), html.Ul([html.Li(e) for e in initial_errors])], color='danger'), hidden, hidden, None
|
| 156 |
+
|
| 157 |
+
unique_files, duplicate_files_meta = [], []
|
| 158 |
+
with engine.connect() as conn:
|
| 159 |
+
for file_data in files_to_process:
|
| 160 |
+
is_duplicate = conn.execute(select(data_penyakit).where(and_(
|
| 161 |
+
data_penyakit.c.bulan == file_data['month'],
|
| 162 |
+
data_penyakit.c.tahun == file_data['year'],
|
| 163 |
+
data_penyakit.c.puskesmas == file_data['pusk']
|
| 164 |
+
)).limit(1)).first()
|
| 165 |
+
if is_duplicate: duplicate_files_meta.append(file_data)
|
| 166 |
+
else: unique_files.append(file_data)
|
| 167 |
+
|
| 168 |
+
# Simpan semua file yang akan diproses ke dcc.Store
|
| 169 |
+
stored_data = {'unique_files': unique_files, 'duplicate_files': duplicate_files_meta}
|
| 170 |
+
|
| 171 |
+
if not duplicate_files_meta: # KASUS 1: Tidak ada duplikat sama sekali
|
| 172 |
+
with engine.connect() as conn:
|
| 173 |
+
for file_data in unique_files:
|
| 174 |
+
file_data['df'] = pd.read_json(io.StringIO(file_data['df_json']), orient='split')
|
| 175 |
+
insert_records(conn, file_data, user_id)
|
| 176 |
+
conn.commit()
|
| 177 |
+
return dbc.Alert(f"✅ Berhasil mengunggah {len(unique_files)} file baru.", color="success"), hidden, hidden, None
|
| 178 |
+
else: # KASUS 2: Ada duplikat, tampilkan konfirmasi
|
| 179 |
+
alert_msg = [html.P(f"⚠️ Ditemukan {len(duplicate_files_meta)} data yang sudah ada di database:")]
|
| 180 |
+
alert_msg.append(html.Ul([html.Li(f['filename']) for f in duplicate_files_meta]))
|
| 181 |
+
if unique_files:
|
| 182 |
+
alert_msg.append(html.P(f"({len(unique_files)} file lainnya adalah data baru)."))
|
| 183 |
+
alert_msg.append(html.P("Apakah Anda ingin menghapus data lama dan mengganti semuanya dengan yang baru?", className="mt-2 fw-bold"))
|
| 184 |
+
return dbc.Alert(alert_msg, color="warning"), visible, visible, json.dumps(stored_data)
|
| 185 |
+
|
| 186 |
+
# --- TAHAP 2: Aksi Hapus & Ganti Jika Tombol Diklik ---
|
| 187 |
+
@callback(
|
| 188 |
+
Output('upload-alert', 'children', allow_duplicate=True),
|
| 189 |
+
Output('replace-multi-button', 'style', allow_duplicate=True),
|
| 190 |
+
Output('cancel-multi-button', 'style', allow_duplicate=True),
|
| 191 |
+
Output('temp-file-storage', 'data', allow_duplicate=True),
|
| 192 |
+
Input('replace-multi-button', 'n_clicks'),
|
| 193 |
+
State('temp-file-storage', 'data'),
|
| 194 |
+
State('login-status', 'data'),
|
| 195 |
+
prevent_initial_call=True
|
| 196 |
+
)
|
| 197 |
+
def execute_replace_multi(n_clicks, stored_data_json, login_data):
|
| 198 |
+
hidden = {'display': 'none'}
|
| 199 |
+
if not n_clicks or not stored_data_json: return no_update, hidden, hidden, no_update
|
| 200 |
+
|
| 201 |
+
stored_data = json.loads(stored_data_json)
|
| 202 |
+
user_id = login_data.get('id_user')
|
| 203 |
+
all_files_to_upload = stored_data['unique_files'] + stored_data['duplicate_files']
|
| 204 |
+
|
| 205 |
+
with engine.connect() as conn:
|
| 206 |
+
# Hapus data lama yang duplikat
|
| 207 |
+
for file_data in stored_data['duplicate_files']:
|
| 208 |
+
stmt_find_old = select(data_penyakit.c.id_data_penyakit).where(and_(
|
| 209 |
+
data_penyakit.c.bulan == file_data['month'], data_penyakit.c.tahun == file_data['year'], data_penyakit.c.puskesmas == file_data['pusk']
|
| 210 |
+
))
|
| 211 |
+
old_ids = [row[0] for row in conn.execute(stmt_find_old).fetchall()]
|
| 212 |
+
if old_ids:
|
| 213 |
+
conn.execute(delete(detail_penyakit).where(detail_penyakit.c.id_data_penyakit.in_(old_ids)))
|
| 214 |
+
conn.execute(delete(data_penyakit).where(data_penyakit.c.id_data_penyakit.in_(old_ids)))
|
| 215 |
+
|
| 216 |
+
# Insert semua data baru (baik yang tadinya unik maupun duplikat)
|
| 217 |
+
for file_data in all_files_to_upload:
|
| 218 |
+
file_data['df'] = pd.read_json(io.StringIO(file_data['df_json']), orient='split')
|
| 219 |
+
insert_records(conn, file_data, user_id)
|
| 220 |
+
|
| 221 |
+
conn.commit()
|
| 222 |
+
|
| 223 |
+
return dbc.Alert(f"✅ Berhasil! Data lama diganti dan {len(all_files_to_upload)} file telah diunggah.", color="success"), hidden, hidden, None
|
| 224 |
+
|
| 225 |
+
# --- TAHAP 2: Aksi Batal Jika Tombol Diklik ---
|
| 226 |
+
@callback(
|
| 227 |
+
Output('upload-alert', 'children', allow_duplicate=True),
|
| 228 |
+
Output('replace-multi-button', 'style', allow_duplicate=True),
|
| 229 |
+
Output('cancel-multi-button', 'style', allow_duplicate=True),
|
| 230 |
+
Output('temp-file-storage', 'data', allow_duplicate=True),
|
| 231 |
+
Input('cancel-multi-button', 'n_clicks'),
|
| 232 |
+
prevent_initial_call=True
|
| 233 |
+
)
|
| 234 |
+
def cancel_multi_upload(n_clicks):
|
| 235 |
+
hidden = {'display': 'none'}
|
| 236 |
+
if n_clicks:
|
| 237 |
+
return dbc.Alert("Proses unggah dibatalkan.", color="info"), hidden, hidden, None
|
| 238 |
+
return no_update, hidden, hidden, no_update
|
| 239 |
+
|
| 240 |
+
# --- Callback untuk Menampilkan Tabel Data Terunggah ---
|
| 241 |
+
@callback(
|
| 242 |
+
Output('uploaded-data-table', 'children'),
|
| 243 |
+
[Input('show-uploaded-data', 'n_clicks'), Input('submit-button-multi', 'n_clicks'), Input('replace-multi-button', 'n_clicks')],
|
| 244 |
+
prevent_initial_call=True
|
| 245 |
+
)
|
| 246 |
+
def show_uploaded_files(n_show, n_submit, n_replace):
|
| 247 |
+
# ... (fungsi ini tidak berubah) ...
|
| 248 |
+
if not ctx.triggered: return ""
|
| 249 |
+
with engine.connect() as conn:
|
| 250 |
+
stmt = select(data_penyakit.c.id_data_penyakit.label('ID'), data_penyakit.c.nama_file.label('Nama File'), data_penyakit.c.bulan.label('Bulan'), data_penyakit.c.tahun.label('Tahun'), data_penyakit.c.puskesmas.label('Puskesmas'), users.c.username.label('Diunggah Oleh')).join(users, data_penyakit.c.id_user == users.c.id_user, isouter=True).order_by(data_penyakit.c.id_data_penyakit.desc())
|
| 251 |
+
df = pd.DataFrame(conn.execute(stmt).fetchall())
|
| 252 |
+
if df.empty: return dbc.Alert("Belum ada data yang diunggah.", color="info")
|
| 253 |
+
return html.Div([html.H5("Data Terunggah ke Database:", className="mt-4"), dash_table.DataTable(columns=[{"name": i, "id": i} for i in df.columns], data=df.to_dict('records'), page_size=10, style_table={'overflowX': 'auto'})])
|
pages/laporan.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# File: pages/laporan.py (Revisi Final - Dashboard Terpadu)
|
| 2 |
+
|
| 3 |
+
from dash import dcc, html, Input, Output, callback, no_update, State, dash_table
|
| 4 |
+
import dash_bootstrap_components as dbc
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import plotly.express as px
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
from sqlalchemy import select, distinct, func, and_
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
# Impor engine dan tabel dari file database.py
|
| 12 |
+
from database import engine, detail_penyakit
|
| 13 |
+
|
| 14 |
+
# Impor library untuk pembuatan PDF
|
| 15 |
+
try:
|
| 16 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, PageBreak, Table, TableStyle
|
| 17 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 18 |
+
from reportlab.lib.units import inch
|
| 19 |
+
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
| 20 |
+
from reportlab.lib import colors as reportlab_colors
|
| 21 |
+
import plotly.io as pio
|
| 22 |
+
if hasattr(pio, 'kaleido'):
|
| 23 |
+
pio.kaleido.scope.mathjax = None
|
| 24 |
+
PDF_CAPABLE = True
|
| 25 |
+
except (AttributeError, ImportError):
|
| 26 |
+
PDF_CAPABLE = False
|
| 27 |
+
print("WARNING: Pustaka 'reportlab' atau 'kaleido' tidak terinstall. Fitur unduh PDF tidak akan berfungsi.")
|
| 28 |
+
|
| 29 |
+
# -----------------------------------------------------------------------------
|
| 30 |
+
# BAGIAN 1: FUNGSI-FUNGSI HELPER (GABUNGAN DARI SEMUA HALAMAN)
|
| 31 |
+
# -----------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
def get_filter_text(pusk, tahun, bulan):
|
| 34 |
+
pusk_txt = "Seluruh Puskesmas" if not pusk else ", ".join(pusk)
|
| 35 |
+
tahun_txt = "Seluruh Tahun" if not tahun else ", ".join(map(str, sorted(tahun)))
|
| 36 |
+
bulan_txt = "Semua Bulan" if not bulan else ", ".join(sorted(bulan))
|
| 37 |
+
return f"Data dari: {pusk_txt} | Tahun: {tahun_txt} | Bulan: {bulan_txt}"
|
| 38 |
+
|
| 39 |
+
# --- Helper untuk Analisis Tren Penyakit ---
|
| 40 |
+
def kategori_penyakit_atp(icd):
|
| 41 |
+
if pd.isna(icd) or str(icd).strip() == "": return 'Tidak Menular'
|
| 42 |
+
icd_clean = str(icd).strip().upper()
|
| 43 |
+
if icd_clean.startswith(('A', 'B')): return 'Menular'
|
| 44 |
+
return 'Tidak Menular'
|
| 45 |
+
|
| 46 |
+
def create_ranking_analysis_text(df):
|
| 47 |
+
if df.empty: return "Tidak ada data untuk dianalisis."
|
| 48 |
+
top_disease, bottom_disease = df.iloc[-1], df.iloc[0]
|
| 49 |
+
return f"- Kasus tertinggi: **{top_disease['jenis_penyakit']}** ({int(top_disease['totall']):,} kasus).\n- Peringkat ke-10: **{bottom_disease['jenis_penyakit']}** ({int(bottom_disease['totall']):,} kasus)."
|
| 50 |
+
|
| 51 |
+
def create_ranking_table(df):
|
| 52 |
+
if df.empty: return None
|
| 53 |
+
df_display = df.copy(); df_display['sort_val'] = pd.to_numeric(df_display['totall'])
|
| 54 |
+
df_display = df_display.sort_values('sort_val', ascending=False).drop(columns=['sort_val'])
|
| 55 |
+
df_display['totall'] = df_display['totall'].apply(lambda x: f"{int(x):,}")
|
| 56 |
+
df_display = df_display.rename(columns={'jenis_penyakit': 'Jenis Penyakit', 'totall': 'Total Kasus'})
|
| 57 |
+
return dash_table.DataTable(data=df_display.to_dict('records'), columns=[{"name": i, "id": i} for i in df_display.columns], style_table={'overflowX': 'auto', 'marginTop': '15px', 'border': '1px solid #ddd'}, style_cell={'textAlign': 'left', 'padding': '8px', 'fontFamily': 'sans-serif'}, style_header={'fontWeight': 'bold', 'backgroundColor': 'rgb(230, 230, 230)'}, style_data_conditional=[{'if': {'row_index': 'odd'}, 'backgroundColor': 'rgb(248, 248, 248)'}])
|
| 58 |
+
|
| 59 |
+
def create_pie_analysis_text(df):
|
| 60 |
+
if df.empty: return "Tidak ada data kategori untuk dianalisis."
|
| 61 |
+
total_cases = df['totall'].sum()
|
| 62 |
+
if total_cases == 0: return "Total kasus adalah nol."
|
| 63 |
+
df['persentase'] = (df['totall'] / total_cases * 100).round(1)
|
| 64 |
+
menular = df[df['kategori'] == 'Menular']; tidak_menular = df[df['kategori'] == 'Tidak Menular']
|
| 65 |
+
perc_menular = menular['persentase'].iloc[0] if not menular.empty else 0
|
| 66 |
+
perc_tidak_menular = tidak_menular['persentase'].iloc[0] if not tidak_menular.empty else 0
|
| 67 |
+
kesimpulan = "dominan" if perc_menular > perc_tidak_menular else "lebih sedikit"
|
| 68 |
+
return f"- Penyakit **Menular** ({perc_menular}%) **{kesimpulan}** dibandingkan Tidak Menular ({perc_tidak_menular}%)."
|
| 69 |
+
|
| 70 |
+
def create_trend_analysis_text(df, time_unit='tahun'):
|
| 71 |
+
if df.empty or (time_unit == 'tahun' and df['tahun'].nunique() < 2): return "Data tidak cukup untuk analisis tren (minimal 2 periode)."
|
| 72 |
+
top_3_diseases = df.groupby('jenis_penyakit')['totall'].sum().nlargest(3).index.tolist()
|
| 73 |
+
if not top_3_diseases: return "Tidak ada data penyakit untuk dianalisis trennya."
|
| 74 |
+
analysis_points = []
|
| 75 |
+
for disease in top_3_diseases:
|
| 76 |
+
df_disease = df[df['jenis_penyakit'] == disease].sort_values(time_unit)
|
| 77 |
+
if len(df_disease) > 1:
|
| 78 |
+
start_val, end_val = df_disease['totall'].iloc[0], df_disease['totall'].iloc[-1]
|
| 79 |
+
if end_val > start_val: tren = f"naik dari {int(start_val):,} menjadi {int(end_val):,}"
|
| 80 |
+
elif end_val < start_val: tren = f"turun dari {int(start_val):,} menjadi {int(end_val):,}"
|
| 81 |
+
else: tren = f"stabil di {int(end_val):,}"
|
| 82 |
+
analysis_points.append(f"- Kasus **{disease}** {tren}.")
|
| 83 |
+
return "Ringkasan Tren 3 Penyakit Teratas:\n\n" + "\n".join(analysis_points)
|
| 84 |
+
|
| 85 |
+
# --- Helper untuk Analisis Demografi ---
|
| 86 |
+
def generate_gender_analysis_text(total_lk, total_pr):
|
| 87 |
+
if total_lk == 0 and total_pr == 0: return "Tidak ada data gender untuk dianalisis."
|
| 88 |
+
total_kasus = total_lk + total_pr
|
| 89 |
+
persen_lk = (total_lk / total_kasus * 100) if total_kasus > 0 else 0
|
| 90 |
+
persen_pr = (total_pr / total_kasus * 100) if total_kasus > 0 else 0
|
| 91 |
+
if persen_lk > persen_pr: kesimpulan = f"lebih banyak menyerang **laki-laki** ({persen_lk:.1f}%)"
|
| 92 |
+
elif persen_pr > persen_lk: kesimpulan = f"lebih banyak menyerang **perempuan** ({persen_pr:.1f}%)"
|
| 93 |
+
else: kesimpulan = f"memiliki distribusi yang **seimbang**"
|
| 94 |
+
return f"Dari total **{int(total_kasus):,}** kasus, penyakit {kesimpulan}."
|
| 95 |
+
|
| 96 |
+
def generate_age_analysis_text(baru_data, lama_data):
|
| 97 |
+
total_kasus_umur = sum(baru_data.values()) + sum(lama_data.values())
|
| 98 |
+
if total_kasus_umur == 0: return "Tidak ada data umur untuk dianalisis."
|
| 99 |
+
total_per_kelompok = {k: baru_data.get(k, 0) + lama_data.get(k, 0) for k in set(baru_data) | set(lama_data)}
|
| 100 |
+
if not total_per_kelompok: return "Tidak ada data umur untuk dianalisis."
|
| 101 |
+
max_total_kelompok = max(total_per_kelompok, key=total_per_kelompok.get)
|
| 102 |
+
return f"Kelompok umur dengan total kasus tertinggi adalah **{max_total_kelompok}** ({int(total_per_kelompok[max_total_kelompok]):,} kasus)."
|
| 103 |
+
|
| 104 |
+
# -----------------------------------------------------------------------------
|
| 105 |
+
# BAGIAN 2: LAYOUT HALAMAN (DENGAN TAB BARU)
|
| 106 |
+
# -----------------------------------------------------------------------------
|
| 107 |
+
layout = dbc.Container([
|
| 108 |
+
dcc.Store(id='laporan-data-store'),
|
| 109 |
+
dcc.Download(id="laporan-download-pdf"),
|
| 110 |
+
dcc.Download(id="laporan-download-excel"),
|
| 111 |
+
dbc.Row([
|
| 112 |
+
dbc.Col(html.H3("Laporan Analisis Terpadu", className="mt-4 mb-4"), md=9),
|
| 113 |
+
dbc.Col(dbc.Button("Unduh Laporan (PDF)", id="laporan-btn-unduh-pdf", color="primary", className="mt-4 float-end", disabled=not PDF_CAPABLE), md=3)
|
| 114 |
+
], align="center"),
|
| 115 |
+
dbc.Card(dbc.CardBody([dbc.Row([
|
| 116 |
+
dbc.Col([dbc.Label("Pilih Puskesmas:"), dcc.Dropdown(id='laporan-pusk-filter', multi=True, placeholder="Pilih...")], md=4),
|
| 117 |
+
dbc.Col([dbc.Label("Pilih Tahun:"), dcc.Dropdown(id='laporan-tahun-filter', multi=True, placeholder="Pilih...")], md=4),
|
| 118 |
+
dbc.Col([dbc.Label("Pilih Bulan (Opsional):"), dcc.Dropdown(id='laporan-bulan-filter', multi=True, placeholder="Pilih Tahun dulu...", disabled=True)], md=4)
|
| 119 |
+
])]), className="mb-3 shadow-sm"),
|
| 120 |
+
html.Div(id='laporan-filter-summary-text', className="text-center text-muted fst-italic mb-3"),
|
| 121 |
+
dcc.Loading(id="laporan-loading-main", type="dot", children=[
|
| 122 |
+
dbc.Tabs(id="laporan-tabs", active_tab='tab-tren', children=[
|
| 123 |
+
dbc.Tab(label="Analisis Tren Penyakit", tab_id="tab-tren", children=html.Div(id='laporan-tab-content-tren')),
|
| 124 |
+
dbc.Tab(label="Analisis Demografi", tab_id="tab-demografi", children=html.Div(id='laporan-tab-content-demografi')),
|
| 125 |
+
])
|
| 126 |
+
]),
|
| 127 |
+
dbc.Card(dbc.CardBody([
|
| 128 |
+
html.H5("Unduh Data Mentah ke Excel", className="card-title"),
|
| 129 |
+
html.P("Filter utama di atas akan diterapkan. Pilih filter penyakit tambahan di bawah jika perlu.", className="card-text"),
|
| 130 |
+
dbc.Row([
|
| 131 |
+
dbc.Col([dbc.Label("Pilih Penyakit (Opsional):"), dcc.Dropdown(id='laporan-unduh-penyakit-filter', multi=True, placeholder="Ketik untuk mencari...")], md=8),
|
| 132 |
+
dbc.Col([html.Div(dbc.Button("Unduh Excel", id='laporan-btn-unduh-excel', color="success", className="w-100"), style={'paddingTop': '31px'})], md=4)
|
| 133 |
+
])
|
| 134 |
+
]), className="my-4 shadow-sm")
|
| 135 |
+
], fluid=True)
|
| 136 |
+
|
| 137 |
+
# -----------------------------------------------------------------------------
|
| 138 |
+
# BAGIAN 3: CALLBACKS
|
| 139 |
+
# -----------------------------------------------------------------------------
|
| 140 |
+
# Callback filter tidak berubah
|
| 141 |
+
@callback(
|
| 142 |
+
Output('laporan-pusk-filter', 'options'),
|
| 143 |
+
Output('laporan-tahun-filter', 'options'),
|
| 144 |
+
Output('laporan-unduh-penyakit-filter', 'options'),
|
| 145 |
+
Input('url', 'pathname')
|
| 146 |
+
)
|
| 147 |
+
def laporan_load_main_filters(pathname):
|
| 148 |
+
if pathname != '/laporan': return no_update, no_update, no_update
|
| 149 |
+
try:
|
| 150 |
+
with engine.connect() as conn:
|
| 151 |
+
pusk_options = [{'label': p[0], 'value': p[0]} for p in conn.execute(select(distinct(detail_penyakit.c.kode_pusk)).order_by(detail_penyakit.c.kode_pusk)).fetchall() if p[0]]
|
| 152 |
+
tahun_options = [{'label': str(t[0]), 'value': t[0]} for t in conn.execute(select(distinct(detail_penyakit.c.tahun)).order_by(detail_penyakit.c.tahun.desc())).fetchall() if t[0]]
|
| 153 |
+
penyakit_options = [{'label': f"{p.jenis_penyakit} ({p.icd_x})", 'value': p.jenis_penyakit} for p in conn.execute(select(distinct(detail_penyakit.c.jenis_penyakit), detail_penyakit.c.icd_x).where(detail_penyakit.c.jenis_penyakit.isnot(None)).order_by(detail_penyakit.c.jenis_penyakit)).fetchall()]
|
| 154 |
+
return pusk_options, tahun_options, penyakit_options
|
| 155 |
+
except Exception as e: print(f"Error load filter laporan: {e}"); return [], [], []
|
| 156 |
+
|
| 157 |
+
@callback(
|
| 158 |
+
Output('laporan-bulan-filter', 'options'),
|
| 159 |
+
Output('laporan-bulan-filter', 'disabled'),
|
| 160 |
+
Output('laporan-bulan-filter', 'value'),
|
| 161 |
+
Input('laporan-tahun-filter', 'value')
|
| 162 |
+
)
|
| 163 |
+
def laporan_update_bulan_filter(selected_tahun):
|
| 164 |
+
if not selected_tahun: return [], True, []
|
| 165 |
+
nama_bulan = {'01':'Januari','02':'Februari','03':'Maret','04':'April','05':'Mei','06':'Juni','07':'Juli','08':'Agustus','09':'September','10':'Oktober','11':'November','12':'Desember'}
|
| 166 |
+
try:
|
| 167 |
+
with engine.connect() as conn:
|
| 168 |
+
stmt = select(distinct(detail_penyakit.c.bulan)).where(detail_penyakit.c.tahun.in_(selected_tahun)).order_by(detail_penyakit.c.bulan)
|
| 169 |
+
bulan_list = [row[0] for row in conn.execute(stmt).fetchall() if row[0]]
|
| 170 |
+
bulan_options = [{'label': nama_bulan.get(b, b), 'value': b} for b in bulan_list]
|
| 171 |
+
return bulan_options, False, []
|
| 172 |
+
except Exception as e: print(f"Error load bulan filter laporan: {e}"); return [], True, []
|
| 173 |
+
|
| 174 |
+
### CALLBACK UTAMA YANG DIGABUNGKAN ###
|
| 175 |
+
@callback(
|
| 176 |
+
Output('laporan-tab-content-tren', 'children'),
|
| 177 |
+
Output('laporan-tab-content-demografi', 'children'),
|
| 178 |
+
Output('laporan-filter-summary-text', 'children'),
|
| 179 |
+
Output('laporan-btn-unduh-pdf', 'disabled'),
|
| 180 |
+
Output('laporan-data-store', 'data'),
|
| 181 |
+
Input('laporan-pusk-filter', 'value'),
|
| 182 |
+
Input('laporan-tahun-filter', 'value'),
|
| 183 |
+
Input('laporan-bulan-filter', 'value')
|
| 184 |
+
)
|
| 185 |
+
def update_laporan_terpadu_tabs(selected_pusk, selected_tahun, selected_bulan):
|
| 186 |
+
if not selected_pusk or not selected_tahun:
|
| 187 |
+
msg = html.P("Silakan pilih minimal Puskesmas dan Tahun.", className="text-center text-primary mt-5")
|
| 188 |
+
return msg, msg, "", True, None
|
| 189 |
+
|
| 190 |
+
filter_summary_text = get_filter_text(selected_pusk, selected_tahun, selected_bulan)
|
| 191 |
+
filters = [detail_penyakit.c.kode_pusk.in_(selected_pusk), detail_penyakit.c.tahun.in_(selected_tahun)]
|
| 192 |
+
if selected_bulan: filters.append(detail_penyakit.c.bulan.in_(selected_bulan))
|
| 193 |
+
|
| 194 |
+
# --- Eksekusi Query Gabungan ---
|
| 195 |
+
all_cols = ['jenis_penyakit', 'icd_x', 'tahun', 'bulan', 'laki_laki', 'perempuan', 'usia_0_7_hr_baru', 'usia_0_7_hr_lama', 'usia_8_28_hr_baru', 'usia_8_28_hr_lama', 'usia_1bl_1th_baru', 'usia_1bl_1th_lama', 'usia_1_4th_baru', 'usia_1_4th_lama', 'usia_5_9th_baru', 'usia_5_9th_lama', 'usia_10_14th_baru', 'usia_10_14th_lama', 'usia_15_19th_baru', 'usia_15_19th_lama', 'usia_20_44th_baru', 'usia_20_44th_lama', 'usia_45_54th_baru', 'usia_45_54th_lama', 'usia_55_59th_baru', 'usia_55_59th_lama', 'usia_60_69th_baru', 'usia_60_69th_lama', 'usia_70pl_baru', 'usia_70pl_lama', 'totall']
|
| 196 |
+
stmt = select(*[getattr(detail_penyakit.c, col) for col in all_cols]).where(and_(*filters))
|
| 197 |
+
|
| 198 |
+
with engine.connect() as conn:
|
| 199 |
+
df_base = pd.read_sql(stmt, conn)
|
| 200 |
+
|
| 201 |
+
if df_base.empty:
|
| 202 |
+
msg = dbc.Alert("Tidak ada data ditemukan untuk kriteria yang dipilih.", color="warning", className="m-4")
|
| 203 |
+
return msg, msg, filter_summary_text, True, None
|
| 204 |
+
|
| 205 |
+
# =========================================================
|
| 206 |
+
# BAGIAN A: Proses Data untuk TAB 1 (Analisis Tren Penyakit)
|
| 207 |
+
# =========================================================
|
| 208 |
+
kode_dihindari = ('V', 'W', 'X', 'Y', 'Z')
|
| 209 |
+
df_base['icd_x_str'] = df_base['icd_x'].astype(str).str.strip().str.upper()
|
| 210 |
+
df_filtered_icd = df_base[~df_base['icd_x_str'].str.startswith(kode_dihindari, na=False)].copy()
|
| 211 |
+
|
| 212 |
+
df_filtered_icd['kategori'] = df_filtered_icd['icd_x'].apply(kategori_penyakit_atp)
|
| 213 |
+
df_ranking_total = df_filtered_icd.groupby('jenis_penyakit')['totall'].sum().nlargest(10).sort_values().reset_index()
|
| 214 |
+
top_10_penyakit_list = df_ranking_total['jenis_penyakit'].tolist()
|
| 215 |
+
df_top10_base = df_filtered_icd[df_filtered_icd['jenis_penyakit'].isin(top_10_penyakit_list)]
|
| 216 |
+
|
| 217 |
+
fig_ranking_simple = px.bar(df_ranking_total, x='totall', y='jenis_penyakit', orientation='h', template='plotly_white', title='<b>Peringkat 10 Penyakit Teratas</b>')
|
| 218 |
+
table_ranking = create_ranking_table(df_ranking_total)
|
| 219 |
+
analysis_ranking = create_ranking_analysis_text(df_ranking_total)
|
| 220 |
+
|
| 221 |
+
df_category_pie = df_filtered_icd.groupby('kategori')['totall'].sum().reset_index()
|
| 222 |
+
fig_category_pie = px.pie(df_category_pie, values='totall', names='kategori', title='<b>Komposisi Menular vs Tidak Menular</b>', color_discrete_map={'Menular':'crimson', 'Tidak Menular':'royalblue'})
|
| 223 |
+
analysis_pie = create_pie_analysis_text(df_category_pie)
|
| 224 |
+
|
| 225 |
+
df_monthly_line = df_top10_base.groupby(['tahun', 'bulan', 'jenis_penyakit'])['totall'].sum().reset_index()
|
| 226 |
+
df_monthly_line['periode'] = df_monthly_line['tahun'].astype(str) + '-' + df_monthly_line['bulan'].str.zfill(2)
|
| 227 |
+
fig_line_monthly = px.line(df_monthly_line.sort_values('periode'), x='periode', y='totall', color='jenis_penyakit', title="<b>Tren Bulanan 10 Penyakit Teratas</b>")
|
| 228 |
+
analysis_monthly_trend = create_trend_analysis_text(df_monthly_line, 'periode')
|
| 229 |
+
|
| 230 |
+
tab1_content = html.Div([
|
| 231 |
+
dbc.Row([
|
| 232 |
+
dbc.Col(dcc.Graph(figure=fig_ranking_simple), md=12),
|
| 233 |
+
dbc.Col([html.H5("Tabel Peringkat", className="mt-3"), table_ranking, dbc.Card(dbc.CardBody(dcc.Markdown(analysis_ranking)), className="mt-3")], md=12),
|
| 234 |
+
], className="mb-4"),
|
| 235 |
+
html.Hr(),
|
| 236 |
+
dbc.Row([
|
| 237 |
+
dbc.Col(dcc.Graph(figure=fig_category_pie), md=6),
|
| 238 |
+
dbc.Col(dcc.Graph(figure=fig_line_monthly), md=6),
|
| 239 |
+
dbc.Col(dbc.Card(dbc.CardBody(dcc.Markdown(analysis_pie))), md=6),
|
| 240 |
+
dbc.Col(dbc.Card(dbc.CardBody(dcc.Markdown(analysis_monthly_trend))), md=6),
|
| 241 |
+
], className="mb-4"),
|
| 242 |
+
], className="mt-3")
|
| 243 |
+
|
| 244 |
+
# =========================================================
|
| 245 |
+
# BAGIAN B: Proses Data untuk TAB 2 (Analisis Demografi)
|
| 246 |
+
# =========================================================
|
| 247 |
+
total_lk = df_base['laki_laki'].sum()
|
| 248 |
+
total_pr = df_base['perempuan'].sum()
|
| 249 |
+
fig_gender = go.Figure(data=[go.Pie(labels=['Laki-laki', 'Perempuan'], values=[total_lk, total_pr], hole=.4, marker_colors=['royalblue', 'crimson'])])
|
| 250 |
+
fig_gender.update_layout(title_text='<b>Distribusi Kasus Berdasarkan Jenis Kelamin</b>', template='plotly_white')
|
| 251 |
+
analysis_gender = generate_gender_analysis_text(total_lk, total_pr)
|
| 252 |
+
|
| 253 |
+
kelompok_map = {'Bayi & Balita (<5th)':['usia_0_7_hr_baru','usia_0_7_hr_lama','usia_8_28_hr_baru','usia_8_28_hr_lama','usia_1bl_1th_baru','usia_1bl_1th_lama','usia_1_4th_baru','usia_1_4th_lama'],'Anak (5-9th)':['usia_5_9th_baru','usia_5_9th_lama'],'Remaja (10-19th)':['usia_10_14th_baru','usia_10_14th_lama','usia_15_19th_baru','usia_15_19th_lama'],'Dewasa (20-59th)':['usia_20_44th_baru','usia_20_44th_lama','usia_45_54th_baru','usia_45_54th_lama','usia_55_59th_baru','usia_55_59th_lama'],'Lansia (60+th)':['usia_60_69th_baru','usia_60_69th_lama','usia_70pl_baru','usia_70pl_lama']}
|
| 254 |
+
age_new_data, age_old_data = {}, {}
|
| 255 |
+
for kelompok, cols in kelompok_map.items():
|
| 256 |
+
age_new_data[kelompok] = df_base[[c for c in cols if 'baru' in c]].sum().sum()
|
| 257 |
+
age_old_data[kelompok] = df_base[[c for c in cols if 'lama' in c]].sum().sum()
|
| 258 |
+
|
| 259 |
+
fig_age = go.Figure(data=[go.Bar(name='Kasus Baru', x=list(age_new_data.keys()), y=list(age_new_data.values())), go.Bar(name='Kasus Lama', x=list(age_old_data.keys()), y=list(age_old_data.values()))]).update_layout(barmode='group', title_text="<b>Distribusi Kasus Berdasarkan Kelompok Umur</b>", template='plotly_white')
|
| 260 |
+
analysis_age = generate_age_analysis_text(age_new_data, age_old_data)
|
| 261 |
+
|
| 262 |
+
tab2_content = html.Div([
|
| 263 |
+
dbc.Row([
|
| 264 |
+
dbc.Col(dcc.Graph(figure=fig_gender), md=6),
|
| 265 |
+
dbc.Col(dcc.Graph(figure=fig_age), md=6),
|
| 266 |
+
dbc.Col(dbc.Card(dbc.CardBody(dcc.Markdown(analysis_gender))), md=6),
|
| 267 |
+
dbc.Col(dbc.Card(dbc.CardBody(dcc.Markdown(analysis_age))), md=6),
|
| 268 |
+
], className="mb-4"),
|
| 269 |
+
], className="mt-3")
|
| 270 |
+
|
| 271 |
+
# --- SIMPAN SEMUA DATA UNTUK DIUNDUH ---
|
| 272 |
+
data_to_store = {
|
| 273 |
+
'figs_json': {
|
| 274 |
+
'ranking_simple': fig_ranking_simple.to_json(), 'category_pie': fig_category_pie.to_json(),
|
| 275 |
+
'line_trend_monthly': fig_line_monthly.to_json(), 'gender_pie': fig_gender.to_json(),
|
| 276 |
+
'age_bar': fig_age.to_json(),
|
| 277 |
+
},
|
| 278 |
+
'table_data': {'ranking': df_ranking_total.to_dict('records')},
|
| 279 |
+
'analysis_texts': {
|
| 280 |
+
'ranking': analysis_ranking, 'pie': analysis_pie, 'monthly_trend': analysis_monthly_trend,
|
| 281 |
+
'gender': analysis_gender, 'age': analysis_age,
|
| 282 |
+
},
|
| 283 |
+
'filter_text': filter_summary_text,
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
return tab1_content, tab2_content, filter_summary_text, not PDF_CAPABLE, data_to_store
|
| 287 |
+
|
| 288 |
+
# Callback 4: Unduh PDF
|
| 289 |
+
@callback(
|
| 290 |
+
Output("laporan-download-pdf", "data"),
|
| 291 |
+
Input("laporan-btn-unduh-pdf", "n_clicks"),
|
| 292 |
+
State("laporan-data-store", "data"),
|
| 293 |
+
prevent_initial_call=True
|
| 294 |
+
)
|
| 295 |
+
def download_laporan_as_pdf(n_clicks, stored_data):
|
| 296 |
+
if not n_clicks or not stored_data or not PDF_CAPABLE: return no_update
|
| 297 |
+
|
| 298 |
+
buffer = io.BytesIO()
|
| 299 |
+
doc = SimpleDocTemplate(buffer, pagesize=(8.5*inch, 11*inch), rightMargin=0.5*inch, leftMargin=0.5*inch, topMargin=0.5*inch, bottomMargin=0.5*inch)
|
| 300 |
+
styles = getSampleStyleSheet()
|
| 301 |
+
style_h1 = ParagraphStyle(name='H1', parent=styles['h1'], alignment=TA_CENTER, fontSize=16, spaceAfter=14)
|
| 302 |
+
style_h2 = ParagraphStyle(name='H2', parent=styles['h2'], alignment=TA_LEFT, fontSize=14, spaceBefore=20, spaceAfter=6, textColor=reportlab_colors.HexColor("#1A3A69"))
|
| 303 |
+
style_body = styles['BodyText']; style_body.leading = 14
|
| 304 |
+
|
| 305 |
+
def fig_to_image(fig_json):
|
| 306 |
+
if not fig_json: return Spacer(1, 0.1 * inch)
|
| 307 |
+
fig = go.Figure(pio.from_json(fig_json)); fig.update_layout(margin=dict(l=20, r=20, t=50, b=20), title_x=0.5)
|
| 308 |
+
img_bytes = pio.to_image(fig, format="png", width=800, height=450, scale=2)
|
| 309 |
+
return Image(io.BytesIO(img_bytes), width=7*inch, height=(7*450/800)*inch)
|
| 310 |
+
|
| 311 |
+
def text_to_paragraph(text_markdown):
|
| 312 |
+
if not isinstance(text_markdown, str): return Paragraph("Analisis tidak tersedia.", style_body)
|
| 313 |
+
parts = text_markdown.replace('\n', '<br/>').split('**')
|
| 314 |
+
for i in range(1, len(parts), 2): parts[i] = f"<b>{parts[i]}</b>"
|
| 315 |
+
return Paragraph("".join(parts), style_body)
|
| 316 |
+
|
| 317 |
+
def create_pdf_table(table_data_records):
|
| 318 |
+
if not table_data_records: return Spacer(1, 0.1*inch)
|
| 319 |
+
headers = ['Jenis Penyakit', 'Total Kasus']; data = [headers]
|
| 320 |
+
for row in table_data_records: data.append([row.get('jenis_penyakit', ''), f"{int(row.get('totall', 0)):,}"])
|
| 321 |
+
table = Table(data, colWidths=[5.5*inch, 1.5*inch])
|
| 322 |
+
table.setStyle(TableStyle([('BACKGROUND', (0,0), (-1,0), reportlab_colors.HexColor("#4682B4")),('TEXTCOLOR',(0,0),(-1,0), reportlab_colors.whitesmoke),('ALIGN', (0,0), (-1,-1), 'LEFT'),('VALIGN', (0,0), (-1,-1), 'MIDDLE'),('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),('BOTTOMPADDING', (0,0), (-1,0), 12),('BACKGROUND', (0,1), (-1,-1), reportlab_colors.HexColor("#F0F8FF")),('GRID', (0,0), (-1,-1), 1, reportlab_colors.black),('ROWBACKGROUNDS', (0,1), (-1,-1), [reportlab_colors.HexColor("#F0F8FF"), reportlab_colors.white])]))
|
| 323 |
+
return table
|
| 324 |
+
|
| 325 |
+
story = [Paragraph("Laporan Analisis Terpadu", style_h1), Paragraph(stored_data.get('filter_text', ''), styles['Italic']), Spacer(1, 0.3*inch)]
|
| 326 |
+
analysis = stored_data.get('analysis_texts', {}); figs = stored_data.get('figs_json', {}); tables = stored_data.get('table_data', {})
|
| 327 |
+
|
| 328 |
+
# HALAMAN TREN PENYAKIT
|
| 329 |
+
story.append(Paragraph("BAGIAN 1: ANALISIS TREN PENYAKIT", style_h2))
|
| 330 |
+
story.append(fig_to_image(figs.get('ranking_simple'))); story.append(create_pdf_table(tables.get('ranking'))); story.append(Spacer(1, 0.1*inch)); story.append(text_to_paragraph(analysis.get('ranking', '...'))); story.append(PageBreak())
|
| 331 |
+
story.append(fig_to_image(figs.get('category_pie'))); story.append(Spacer(1, 0.1*inch)); story.append(text_to_paragraph(analysis.get('pie', '...'))); story.append(Spacer(1, 0.3*inch))
|
| 332 |
+
story.append(fig_to_image(figs.get('line_trend_monthly'))); story.append(Spacer(1, 0.1*inch)); story.append(text_to_paragraph(analysis.get('monthly_trend', '...'))); story.append(PageBreak())
|
| 333 |
+
|
| 334 |
+
# HALAMAN ANALISIS DEMOGRAFI
|
| 335 |
+
story.append(Paragraph("BAGIAN 2: ANALISIS DEMOGRAFI", style_h2))
|
| 336 |
+
story.append(fig_to_image(figs.get('gender_pie'))); story.append(Spacer(1, 0.1*inch)); story.append(text_to_paragraph(analysis.get('gender', '...')))
|
| 337 |
+
story.append(Spacer(1, 0.3*inch)); story.append(fig_to_image(figs.get('age_bar'))); story.append(Spacer(1, 0.1*inch)); story.append(text_to_paragraph(analysis.get('age', '...')))
|
| 338 |
+
|
| 339 |
+
doc.build(story); return dcc.send_bytes(buffer.getvalue(), "Laporan_Terpadu_Penyakit.pdf")
|
| 340 |
+
|
| 341 |
+
# Callback 5: Unduh Excel
|
| 342 |
+
@callback(
|
| 343 |
+
Output("laporan-download-excel", "data"),
|
| 344 |
+
Input("laporan-btn-unduh-excel", "n_clicks"),
|
| 345 |
+
State("laporan-pusk-filter", "value"),
|
| 346 |
+
State("laporan-tahun-filter", "value"),
|
| 347 |
+
State("laporan-bulan-filter", "value"),
|
| 348 |
+
State("laporan-unduh-penyakit-filter", "value"),
|
| 349 |
+
prevent_initial_call=True,
|
| 350 |
+
)
|
| 351 |
+
def download_data_as_excel(n_clicks, pusk, tahun, bulan, penyakit):
|
| 352 |
+
if not n_clicks: return no_update
|
| 353 |
+
filters = []
|
| 354 |
+
if pusk: filters.append(detail_penyakit.c.kode_pusk.in_(pusk))
|
| 355 |
+
if tahun: filters.append(detail_penyakit.c.tahun.in_(tahun))
|
| 356 |
+
if bulan: filters.append(detail_penyakit.c.bulan.in_(bulan))
|
| 357 |
+
if penyakit: filters.append(detail_penyakit.c.jenis_penyakit.in_(penyakit))
|
| 358 |
+
if not filters: return no_update
|
| 359 |
+
stmt = select(detail_penyakit).where(and_(*filters))
|
| 360 |
+
with engine.connect() as conn: df_to_download = pd.read_sql(stmt, conn)
|
| 361 |
+
if df_to_download.empty: return no_update
|
| 362 |
+
output = io.BytesIO()
|
| 363 |
+
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
|
| 364 |
+
df_to_download.to_excel(writer, index=False, sheet_name='Data_Penyakit_Terfilter')
|
| 365 |
+
return dcc.send_bytes(output.getvalue(), "laporan_data_mentah.xlsx")
|
pages/logout.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pages/logout.py
|
| 2 |
+
from dash import html
|
| 3 |
+
|
| 4 |
+
logout_layout = html.Div([
|
| 5 |
+
# Konten bisa kosong, atau pesan sederhana
|
| 6 |
+
# html.P("Memproses logout...", className="text-center mt-5")
|
| 7 |
+
])
|
| 8 |
+
layout = logout_layout
|
pages/pengaturan.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pages/pengaturan.py
|
| 2 |
+
from dash import dash, dcc, html, Input, Output, State, callback
|
| 3 |
+
import dash_bootstrap_components as dbc
|
| 4 |
+
from sqlalchemy import select, update
|
| 5 |
+
from werkzeug.security import check_password_hash, generate_password_hash # Pastikan ini diimport
|
| 6 |
+
from database import engine, users # Pastikan ini diimport
|
| 7 |
+
|
| 8 |
+
# Layout Halaman Pengaturan
|
| 9 |
+
layout = dbc.Container([
|
| 10 |
+
html.H3("Pengaturan", className="mt-4 mb-4"),
|
| 11 |
+
dcc.Store(id='logged-in-user-data-pengaturan', storage_type='session'),
|
| 12 |
+
# 'user-theme-preference-store' ada di app.py layout, jadi tidak perlu di sini lagi jika sudah global.
|
| 13 |
+
# Namun, jika hanya untuk halaman ini, bisa ditaruh di sini. Agar global, lebih baik di app.py.
|
| 14 |
+
# Kita asumsikan 'user-theme-preference-store' sudah ada di app.layout
|
| 15 |
+
|
| 16 |
+
dbc.Alert(id="pengaturan-alert-placeholder", color="info", is_open=False, duration=5000, dismissable=True),
|
| 17 |
+
|
| 18 |
+
dbc.Tabs([
|
| 19 |
+
dbc.Tab(label="Profil Pengguna", tab_id="tab-profil", children=[
|
| 20 |
+
dbc.Card(dbc.CardBody([
|
| 21 |
+
html.H5("Informasi Profil", className="card-title mb-4"),
|
| 22 |
+
dbc.Row([
|
| 23 |
+
dbc.Col(dbc.Label("Nama Lengkap:", html_for="pengaturan-nama-lengkap"), md=3, className="col-form-label"),
|
| 24 |
+
dbc.Col(dcc.Input(id="pengaturan-nama-lengkap", type="text", className="form-control", placeholder="Masukkan nama lengkap Anda"), md=9)
|
| 25 |
+
], className="mb-3 align-items-center"),
|
| 26 |
+
dbc.Row([
|
| 27 |
+
dbc.Col(dbc.Label("Username:", html_for="pengaturan-username"), md=3, className="col-form-label"),
|
| 28 |
+
dbc.Col(html.P(id="pengaturan-username", className="form-control-plaintext pt-2"), md=9)
|
| 29 |
+
], className="mb-3 align-items-center"),
|
| 30 |
+
dbc.Row([
|
| 31 |
+
dbc.Col(dbc.Label("Email:", html_for="pengaturan-email"), md=3, className="col-form-label"),
|
| 32 |
+
dbc.Col(dcc.Input(id="pengaturan-email", type="email", className="form-control", placeholder="Masukkan alamat email Anda"), md=9)
|
| 33 |
+
], className="mb-3 align-items-center"),
|
| 34 |
+
dbc.Row([
|
| 35 |
+
dbc.Col(dbc.Label("Jabatan:", html_for="pengaturan-jabatan"), md=3, className="col-form-label"),
|
| 36 |
+
dbc.Col(html.P(id="pengaturan-jabatan", className="form-control-plaintext pt-2"), md=9)
|
| 37 |
+
], className="mb-3 align-items-center"),
|
| 38 |
+
dbc.Button("Simpan Perubahan Profil", id="simpan-profil-button", color="primary", n_clicks=0, className="mt-3")
|
| 39 |
+
]))
|
| 40 |
+
]),
|
| 41 |
+
dbc.Tab(label="Ubah Password", tab_id="tab-password", children=[
|
| 42 |
+
dbc.Card(dbc.CardBody([
|
| 43 |
+
html.H5("Ubah Password", className="card-title mb-4"),
|
| 44 |
+
dbc.Row([
|
| 45 |
+
dbc.Col(dbc.Label("Password Lama:", html_for="pengaturan-password-lama"), md=4, className="col-form-label"),
|
| 46 |
+
dbc.Col(dcc.Input(id="pengaturan-password-lama", type="password", className="form-control", placeholder="Masukkan password lama Anda"), md=8)
|
| 47 |
+
], className="mb-3 align-items-center"),
|
| 48 |
+
dbc.Row([
|
| 49 |
+
dbc.Col(dbc.Label("Password Baru:", html_for="pengaturan-password-baru"), md=4, className="col-form-label"),
|
| 50 |
+
dbc.Col(dcc.Input(id="pengaturan-password-baru", type="password", className="form-control", placeholder="Minimal 6 karakter"), md=8)
|
| 51 |
+
], className="mb-3 align-items-center"),
|
| 52 |
+
dbc.Row([
|
| 53 |
+
dbc.Col(dbc.Label("Konfirmasi Password Baru:", html_for="pengaturan-konfirmasi-password-baru"), md=4, className="col-form-label"),
|
| 54 |
+
dbc.Col(dcc.Input(id="pengaturan-konfirmasi-password-baru", type="password", className="form-control", placeholder="Ulangi password baru"), md=8)
|
| 55 |
+
], className="mb-3 align-items-center"),
|
| 56 |
+
dbc.Button("Ubah Password", id="ubah-password-button", color="warning", n_clicks=0, className="mt-3")
|
| 57 |
+
]))
|
| 58 |
+
]),
|
| 59 |
+
dbc.Tab(label="Tampilan", tab_id="tab-tampilan", children=[
|
| 60 |
+
dbc.Card(dbc.CardBody([
|
| 61 |
+
html.H5("Preferensi Tampilan", className="card-title mb-4"),
|
| 62 |
+
dbc.Row([
|
| 63 |
+
dbc.Col(dbc.Label("Pilih Tema:", html_for="tema-dropdown-pengaturan"), md=3, className="col-form-label"),
|
| 64 |
+
dbc.Col(dcc.Dropdown(
|
| 65 |
+
id='tema-dropdown-pengaturan', # Beri ID unik jika 'tema-dropdown' dipakai di tempat lain
|
| 66 |
+
options=[
|
| 67 |
+
{'label': 'Terang (Default)', 'value': 'LIGHT'},
|
| 68 |
+
{'label': 'Gelap', 'value': 'DARK'},
|
| 69 |
+
],
|
| 70 |
+
value='LIGHT',
|
| 71 |
+
clearable=False
|
| 72 |
+
), md=5)
|
| 73 |
+
], className="mb-3 align-items-center"),
|
| 74 |
+
html.P(id="theme-change-message", className="text-muted small")
|
| 75 |
+
]))
|
| 76 |
+
]),
|
| 77 |
+
dbc.Tab(label="Tentang Aplikasi", tab_id="tab-tentang", children=[
|
| 78 |
+
dbc.Card(dbc.CardBody([
|
| 79 |
+
html.H5("Tentang Aplikasi Ini", className="card-title mb-4"),
|
| 80 |
+
html.P("Aplikasi Dashboard Analisis Data Penyakit V1.0", className="lead"),
|
| 81 |
+
html.Hr(),
|
| 82 |
+
dbc.Row([
|
| 83 |
+
dbc.Col(html.Strong("Dikembangkan oleh:"), width="auto", className="pe-0"),
|
| 84 |
+
dbc.Col("Ika Putri Salsabila")
|
| 85 |
+
]),
|
| 86 |
+
dbc.Row([
|
| 87 |
+
dbc.Col(html.Strong("Institusi:"), width="auto", className="pe-0"),
|
| 88 |
+
dbc.Col("Program Studi D4 Manajemen Informasi Kesehatan, Universitas Gadjah Mada")
|
| 89 |
+
]),
|
| 90 |
+
dbc.Row([
|
| 91 |
+
dbc.Col(html.Strong("Tujuan:"), width="auto", className="pe-0"),
|
| 92 |
+
dbc.Col("Aplikasi ini dirancang untuk membantu dalam visualisasi dan analisis tren serta distribusi kasus penyakit. Tujuannya adalah untuk menyediakan platform interaktif guna mempermudah pemahaman pola penyakit sebagai bagian dari pengerjaan skripsi.")
|
| 93 |
+
]),
|
| 94 |
+
]))
|
| 95 |
+
])
|
| 96 |
+
], id="pengaturan-tabs", active_tab="tab-profil")
|
| 97 |
+
], fluid=True)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# --- CALLBACKS PROFIL & PASSWORD ---
|
| 101 |
+
# (Callback ini sama seperti yang sudah kita buat dan uji sebelumnya)
|
| 102 |
+
|
| 103 |
+
@callback(
|
| 104 |
+
Output('pengaturan-nama-lengkap', 'value'),
|
| 105 |
+
Output('pengaturan-username', 'children'),
|
| 106 |
+
Output('pengaturan-email', 'value'),
|
| 107 |
+
Output('pengaturan-jabatan', 'children'),
|
| 108 |
+
Output('logged-in-user-data-pengaturan', 'data'),
|
| 109 |
+
Input('login-status', 'data'), # Pemicu utama saat login
|
| 110 |
+
Input('pengaturan-tabs', 'active_tab') # Pemicu saat tab profil di klik
|
| 111 |
+
)
|
| 112 |
+
def load_user_profile_data_settings(login_data, active_tab):
|
| 113 |
+
if not login_data or not login_data.get('logged_in'):
|
| 114 |
+
return "", "N/A", "", "N/A", {}
|
| 115 |
+
current_username = login_data.get('username')
|
| 116 |
+
if not current_username:
|
| 117 |
+
return "", "N/A", "", "N/A", {}
|
| 118 |
+
|
| 119 |
+
if active_tab == "tab-profil":
|
| 120 |
+
with engine.connect() as conn:
|
| 121 |
+
user_db_data = conn.execute(
|
| 122 |
+
select(users.c.nama_lengkap, users.c.email, users.c.jabatan)
|
| 123 |
+
.where(users.c.username == current_username)
|
| 124 |
+
).fetchone()
|
| 125 |
+
if user_db_data:
|
| 126 |
+
return (user_db_data.nama_lengkap or "", current_username, user_db_data.email or "",
|
| 127 |
+
user_db_data.jabatan or "N/A", {'username': current_username})
|
| 128 |
+
else:
|
| 129 |
+
return "Error", current_username, "Error", "Error", {'username': current_username}
|
| 130 |
+
return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@callback(
|
| 134 |
+
Output('pengaturan-alert-placeholder', 'children'),
|
| 135 |
+
Output('pengaturan-alert-placeholder', 'is_open'),
|
| 136 |
+
Output('pengaturan-alert-placeholder', 'color'),
|
| 137 |
+
Input('simpan-profil-button', 'n_clicks'),
|
| 138 |
+
State('logged-in-user-data-pengaturan', 'data'),
|
| 139 |
+
State('pengaturan-nama-lengkap', 'value'),
|
| 140 |
+
State('pengaturan-email', 'value'),
|
| 141 |
+
prevent_initial_call=True
|
| 142 |
+
)
|
| 143 |
+
def save_profile_changes_settings(n_clicks, user_session_data, nama_lengkap, email):
|
| 144 |
+
if not n_clicks or not user_session_data or not user_session_data.get('username'):
|
| 145 |
+
return dash.no_update, False, "info"
|
| 146 |
+
|
| 147 |
+
username = user_session_data.get('username')
|
| 148 |
+
if not nama_lengkap or not email:
|
| 149 |
+
return "Nama lengkap dan Email tidak boleh kosong.", True, "danger"
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
with engine.connect() as conn:
|
| 153 |
+
stmt = (
|
| 154 |
+
update(users)
|
| 155 |
+
.where(users.c.username == username)
|
| 156 |
+
.values(nama_lengkap=nama_lengkap, email=email)
|
| 157 |
+
)
|
| 158 |
+
result = conn.execute(stmt)
|
| 159 |
+
conn.commit()
|
| 160 |
+
if result.rowcount > 0:
|
| 161 |
+
return "Profil berhasil diperbarui.", True, "success"
|
| 162 |
+
else:
|
| 163 |
+
return "Tidak ada perubahan pada profil atau pengguna tidak ditemukan.", True, "warning"
|
| 164 |
+
except Exception as e:
|
| 165 |
+
return f"Terjadi kesalahan: {str(e)[:100]}", True, "danger"
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# Callback untuk mengubah password
|
| 169 |
+
# pages/pengaturan.py
|
| 170 |
+
# ... (import dan layout dan callback profil tetap sama) ...
|
| 171 |
+
|
| 172 |
+
# Callback untuk mengubah password
|
| 173 |
+
@callback(
|
| 174 |
+
Output('pengaturan-alert-placeholder', 'children', allow_duplicate=True),
|
| 175 |
+
Output('pengaturan-alert-placeholder', 'is_open', allow_duplicate=True),
|
| 176 |
+
Output('pengaturan-alert-placeholder', 'color', allow_duplicate=True),
|
| 177 |
+
Output('pengaturan-password-lama', 'value'),
|
| 178 |
+
Output('pengaturan-password-baru', 'value'),
|
| 179 |
+
Output('pengaturan-konfirmasi-password-baru', 'value'),
|
| 180 |
+
Input('ubah-password-button', 'n_clicks'),
|
| 181 |
+
State('logged-in-user-data-pengaturan', 'data'), # Username dari Store
|
| 182 |
+
State('pengaturan-password-lama', 'value'),
|
| 183 |
+
State('pengaturan-password-baru', 'value'),
|
| 184 |
+
State('pengaturan-konfirmasi-password-baru', 'value'),
|
| 185 |
+
prevent_initial_call=True
|
| 186 |
+
)
|
| 187 |
+
def change_user_password_settings(n_clicks_pw, user_session_data_pw, old_password_input_pw, new_password_pw, confirm_new_password_pw):
|
| 188 |
+
# Menggunakan nama variabel yang berbeda untuk state di dalam fungsi ini
|
| 189 |
+
# untuk menghindari kebingungan dengan state dari callback lain jika ada.
|
| 190 |
+
|
| 191 |
+
if not n_clicks_pw or not user_session_data_pw or not user_session_data_pw.get('username'):
|
| 192 |
+
return dash.no_update, False, "info", dash.no_update, dash.no_update, dash.no_update
|
| 193 |
+
|
| 194 |
+
username_pw = user_session_data_pw.get('username')
|
| 195 |
+
print(f"--- PW_CHANGE_DEBUG: Memulai ubah password untuk User: {username_pw} ---")
|
| 196 |
+
print(f"--- PW_CHANGE_DEBUG: Input Password Lama: '{old_password_input_pw}' ---")
|
| 197 |
+
|
| 198 |
+
if not old_password_input_pw or not new_password_pw or not confirm_new_password_pw:
|
| 199 |
+
return "Semua field password harus diisi.", True, "danger", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 200 |
+
|
| 201 |
+
if new_password_pw != confirm_new_password_pw:
|
| 202 |
+
return "Password baru dan konfirmasi password tidak cocok.", True, "danger", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 203 |
+
|
| 204 |
+
if len(new_password_pw) < 6:
|
| 205 |
+
return "Password baru minimal 6 karakter.", True, "danger", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
with engine.connect() as conn_pw: # Menggunakan variabel koneksi yang berbeda
|
| 209 |
+
# 1. Ambil HASHED password lama dari DB untuk user yang sedang login
|
| 210 |
+
stmt_select_pass = select(users.c.password).where(users.c.username == username_pw)
|
| 211 |
+
user_db_record = conn_pw.execute(stmt_select_pass).fetchone()
|
| 212 |
+
|
| 213 |
+
if not user_db_record:
|
| 214 |
+
print(f"--- PW_CHANGE_DEBUG: User '{username_pw}' tidak ditemukan di database. ---")
|
| 215 |
+
return "Pengguna tidak ditemukan. Sesi mungkin tidak valid.", True, "danger", "", "", ""
|
| 216 |
+
|
| 217 |
+
hashed_password_from_db = user_db_record.password
|
| 218 |
+
if not hashed_password_from_db: # Kemungkinan kolom password NULL/kosong
|
| 219 |
+
print(f"--- PW_CHANGE_DEBUG: Password di DB untuk '{username_pw}' KOSONG/NULL. ---")
|
| 220 |
+
return "Data password di server tidak valid. Hubungi administrator.", True, "danger", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
print(f"--- PW_CHANGE_DEBUG: Hashed Password dari DB untuk '{username_pw}': {str(hashed_password_from_db)[:20]}... ---")
|
| 224 |
+
|
| 225 |
+
# 2. Verifikasi input password lama dengan HASHED password dari DB
|
| 226 |
+
# Pastikan old_password_input_pw adalah string yang bersih
|
| 227 |
+
clean_old_password_input = str(old_password_input_pw).strip()
|
| 228 |
+
|
| 229 |
+
# PENTING: Pastikan hashed_password_from_db adalah string
|
| 230 |
+
# Beberapa driver database mungkin mengembalikan tipe data lain (mis. bytes)
|
| 231 |
+
is_old_password_correct = check_password_hash(str(hashed_password_from_db), clean_old_password_input)
|
| 232 |
+
|
| 233 |
+
print(f"--- PW_CHANGE_DEBUG: Input bersih password lama: '{clean_old_password_input}' ---")
|
| 234 |
+
print(f"--- PW_CHANGE_DEBUG: Hasil check_password_hash (DB vs Input): {is_old_password_correct} ---")
|
| 235 |
+
|
| 236 |
+
if not is_old_password_correct:
|
| 237 |
+
return "Password lama salah.", True, "danger", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 238 |
+
|
| 239 |
+
# 3. Generate hash untuk password baru
|
| 240 |
+
hashed_new_password = generate_password_hash(str(new_password_pw).strip()) # Pastikan juga password baru di-strip
|
| 241 |
+
print(f"--- PW_CHANGE_DEBUG: Hashed Password BARU yang akan disimpan: {hashed_new_password[:20]}... ---")
|
| 242 |
+
|
| 243 |
+
# 4. Update password di DB
|
| 244 |
+
stmt_update_pass = (
|
| 245 |
+
update(users)
|
| 246 |
+
.where(users.c.username == username_pw)
|
| 247 |
+
.values(password=hashed_new_password)
|
| 248 |
+
)
|
| 249 |
+
result_update = conn_pw.execute(stmt_update_pass)
|
| 250 |
+
conn_pw.commit()
|
| 251 |
+
|
| 252 |
+
if result_update.rowcount > 0:
|
| 253 |
+
print(f"--- PW_CHANGE_DEBUG: Password untuk '{username_pw}' berhasil diubah di DB. ---")
|
| 254 |
+
return "Password berhasil diubah.", True, "success", "", "", ""
|
| 255 |
+
else:
|
| 256 |
+
print(f"--- PW_CHANGE_DEBUG: Gagal update password untuk '{username_pw}' di DB (rowcount 0 setelah verifikasi benar). Kemungkinan tidak ada perubahan nilai hash.---")
|
| 257 |
+
# Ini bisa terjadi jika password baru SAMA dengan password lama, sehingga hashnya sama dan DB tidak menganggapnya sebagai update.
|
| 258 |
+
# Atau ada masalah aneh lain.
|
| 259 |
+
return "Gagal mengubah password (tidak ada baris yang terupdate).", True, "warning", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 260 |
+
|
| 261 |
+
except Exception as e_pw:
|
| 262 |
+
print(f"--- PW_CHANGE_DEBUG: Exception saat ubah password: {e_pw} ---")
|
| 263 |
+
import traceback
|
| 264 |
+
traceback.print_exc() # Cetak traceback lengkap untuk debugging lebih lanjut
|
| 265 |
+
return f"Terjadi kesalahan server saat mengubah password.", True, "danger", old_password_input_pw, new_password_pw, confirm_new_password_pw
|
| 266 |
+
|
| 267 |
+
# Callback untuk menyimpan preferensi tema ke 'user-theme-preference-store' (ada di app.py)
|
| 268 |
+
# ketika pengguna mengubah dropdown di halaman pengaturan.
|
| 269 |
+
@callback(
|
| 270 |
+
Output('user-theme-preference-store', 'data', allow_duplicate=True), # Output ke store global
|
| 271 |
+
Output('theme-change-message', 'children'), # Pesan untuk user
|
| 272 |
+
Input('tema-dropdown-pengaturan', 'value'),
|
| 273 |
+
State('user-theme-preference-store', 'data'), # Ambil state saat ini untuk perbandingan
|
| 274 |
+
prevent_initial_call=True # Hanya jalan jika dropdown diubah oleh user
|
| 275 |
+
)
|
| 276 |
+
def update_theme_preference_from_settings_dropdown(selected_theme_value, current_stored_data):
|
| 277 |
+
message = ""
|
| 278 |
+
# Hanya update store jika nilai dropdown berbeda dari yang sudah tersimpan
|
| 279 |
+
if current_stored_data and current_stored_data.get('theme') == selected_theme_value:
|
| 280 |
+
# message = "Tema sudah sesuai preferensi tersimpan." # Opsional
|
| 281 |
+
return dash.no_update, message
|
| 282 |
+
|
| 283 |
+
if selected_theme_value:
|
| 284 |
+
# print(f"Pengaturan: Tema dipilih '{selected_theme_value}', menyimpan ke user-theme-preference-store.")
|
| 285 |
+
message = f"Tema {selected_theme_value.lower()} dipilih. Efek akan terlihat di seluruh aplikasi."
|
| 286 |
+
return {'theme': selected_theme_value}, message
|
| 287 |
+
return dash.no_update, ""
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# Callback untuk memuat preferensi tema dari 'user-theme-preference-store' (global)
|
| 291 |
+
# ke dropdown di halaman pengaturan saat tab "Tampilan" dibuka.
|
| 292 |
+
@callback(
|
| 293 |
+
Output('tema-dropdown-pengaturan', 'value'),
|
| 294 |
+
Input('pengaturan-tabs', 'active_tab'), # Pemicu saat tab "Tampilan" aktif
|
| 295 |
+
State('user-theme-preference-store', 'data') # Ambil data yang sudah tersimpan dari store global
|
| 296 |
+
)
|
| 297 |
+
def load_theme_to_settings_dropdown(active_tab, stored_theme_data):
|
| 298 |
+
if active_tab == 'tab-tampilan':
|
| 299 |
+
if stored_theme_data and 'theme' in stored_theme_data:
|
| 300 |
+
# print(f"Pengaturan: Memuat tema '{stored_theme_data['theme']}' dari store ke dropdown.")
|
| 301 |
+
return stored_theme_data['theme']
|
| 302 |
+
# print("Pengaturan: Tidak ada tema di store, dropdown diatur ke LIGHT (default).")
|
| 303 |
+
return 'LIGHT'
|
| 304 |
+
return dash.no_update
|
requirements.txt
ADDED
|
Binary file (3.09 kB). View file
|
|
|