aslasacacc commited on
Commit
4253e50
·
0 Parent(s):

The real final commit, clean start

Browse files
.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