import streamlit as st import pandas as pd import sqlite3 import os import calendar from datetime import date, datetime # --- KONFIGURACJA --- st.set_page_config(page_title="Finanse Domowe", layout="wide", page_icon="💰") DB_FILE = 'data/finanse.db' # --- CSS (STYLIZACJA) --- st.markdown(""" """, unsafe_allow_html=True) # --- BEZPIECZEŃSTWO --- def check_password(): if "password_correct" not in st.session_state: st.session_state.password_correct = False if st.session_state.password_correct: return True col1, col2, col3 = st.columns([1,2,1]) with col2: st.markdown("### 🔒 Dostęp autoryzowany") password = st.text_input("Podaj hasło", type="password") if st.button("Zaloguj"): secret_pass = os.environ.get("APP_PASSWORD", "admin") if password == secret_pass: st.session_state.password_correct = True st.rerun() else: st.error("Błędne hasło!") return False if not check_password(): st.stop() # --- BAZA DANYCH --- def init_db(): os.makedirs(os.path.dirname(DB_FILE), exist_ok=True) conn = sqlite3.connect(DB_FILE) c = conn.cursor() c.execute(''' CREATE TABLE IF NOT EXISTS wydatki ( id INTEGER PRIMARY KEY AUTOINCREMENT, nazwa TEXT NOT NULL, kategoria TEXT, kwota REAL, termin DATE, cykliczne BOOLEAN, zaplacone BOOLEAN ) ''') try: c.execute("ALTER TABLE wydatki ADD COLUMN interwal_ilosc INTEGER DEFAULT 1") except sqlite3.OperationalError: pass try: c.execute("ALTER TABLE wydatki ADD COLUMN interwal_typ TEXT DEFAULT 'miesiące'") except sqlite3.OperationalError: pass c.execute('''CREATE TABLE IF NOT EXISTS kategorie (nazwa TEXT PRIMARY KEY)''') c.execute('''CREATE TABLE IF NOT EXISTS budzety (kategoria TEXT PRIMARY KEY, limit_kwota REAL)''') c.execute(''' CREATE TABLE IF NOT EXISTS cele ( id INTEGER PRIMARY KEY AUTOINCREMENT, nazwa TEXT NOT NULL, kwota_cel REAL, kwota_obecna REAL DEFAULT 0, termin DATE, ikona TEXT ) ''') c.execute("SELECT count(*) FROM kategorie") if c.fetchone()[0] == 0: default_cats = [("Domowe",), ("Firmowe",), ("Podatki",), ("Paliwo",), ("Jedzenie",)] c.executemany("INSERT INTO kategorie (nazwa) VALUES (?)", default_cats) conn.commit() conn.close() def get_categories(): conn = sqlite3.connect(DB_FILE) df = pd.read_sql_query("SELECT nazwa FROM kategorie ORDER BY nazwa", conn) conn.close() return df['nazwa'].tolist() def add_category_db(name): conn = sqlite3.connect(DB_FILE) try: conn.execute("INSERT INTO kategorie (nazwa) VALUES (?)", (name,)) conn.commit() return True except sqlite3.IntegrityError: return False finally: conn.close() def delete_category_db(name): conn = sqlite3.connect(DB_FILE) conn.execute("DELETE FROM kategorie WHERE nazwa = ?", (name,)) conn.execute("DELETE FROM budzety WHERE kategoria = ?", (name,)) conn.commit() conn.close() def get_budgets(): conn = sqlite3.connect(DB_FILE) df = pd.read_sql_query("SELECT * FROM budzety", conn) conn.close() if df.empty: return {} return dict(zip(df['kategoria'], df['limit_kwota'])) def set_budget_db(category, limit): conn = sqlite3.connect(DB_FILE) conn.execute("INSERT INTO budzety (kategoria, limit_kwota) VALUES (?, ?) ON CONFLICT(kategoria) DO UPDATE SET limit_kwota=?", (category, limit, limit)) conn.commit() conn.close() def delete_budget_db(category): conn = sqlite3.connect(DB_FILE) conn.execute("DELETE FROM budzety WHERE kategoria = ?", (category,)) conn.commit() conn.close() def add_goal(nazwa, cel, termin, ikona="🎯"): conn = sqlite3.connect(DB_FILE) conn.execute("INSERT INTO cele (nazwa, kwota_cel, kwota_obecna, termin, ikona) VALUES (?, ?, ?, ?, ?)", (nazwa, cel, 0.0, termin, ikona)) conn.commit() conn.close() def get_goals(): conn = sqlite3.connect(DB_FILE) df = pd.read_sql_query("SELECT * FROM cele", conn) conn.close() if not df.empty: df['termin'] = pd.to_datetime(df['termin'], errors='coerce').dt.date return df def update_goal_amount(goal_id, amount_to_add): conn = sqlite3.connect(DB_FILE) conn.execute("UPDATE cele SET kwota_obecna = kwota_obecna + ? WHERE id = ?", (amount_to_add, goal_id)) conn.commit() conn.close() def delete_goal(goal_id): conn = sqlite3.connect(DB_FILE) conn.execute("DELETE FROM cele WHERE id = ?", (goal_id,)) conn.commit() conn.close() def load_data(): conn = sqlite3.connect(DB_FILE) df = pd.read_sql_query("SELECT * FROM wydatki", conn) conn.close() if not df.empty: df['termin'] = pd.to_datetime(df['termin']).dt.date df['cykliczne'] = df['cykliczne'].astype(bool) df['zaplacone'] = df['zaplacone'].astype(bool) if 'interwal_ilosc' in df.columns: df['interwal_ilosc'] = pd.to_numeric(df['interwal_ilosc'], errors='coerce') df['interwal_ilosc'] = df['interwal_ilosc'].fillna(1).astype(int) if 'interwal_typ' in df.columns: df['interwal_typ'] = df['interwal_typ'].fillna('miesiące').astype(str) return df def add_expense(nazwa, kategoria, kwota, termin, cykliczne, interwal_ilosc=1, interwal_typ='miesiące'): conn = sqlite3.connect(DB_FILE) c = conn.cursor() safe_qty = int(interwal_ilosc) safe_type = str(interwal_typ) c.execute('''INSERT INTO wydatki (nazwa, kategoria, kwota, termin, cykliczne, zaplacone, interwal_ilosc, interwal_typ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', (nazwa, kategoria, kwota, termin, cykliczne, False, safe_qty, safe_type)) conn.commit() conn.close() def update_status(expense_id, is_paid): conn = sqlite3.connect(DB_FILE) c = conn.cursor() c.execute("UPDATE wydatki SET zaplacone = ? WHERE id = ?", (is_paid, expense_id)) conn.commit() conn.close() def update_expense_details(expense_id, nazwa, kategoria, kwota, termin): conn = sqlite3.connect(DB_FILE) c = conn.cursor() c.execute("UPDATE wydatki SET nazwa=?, kategoria=?, kwota=?, termin=? WHERE id=?", (nazwa, kategoria, kwota, termin, expense_id)) conn.commit() conn.close() def delete_expense(expense_id): conn = sqlite3.connect(DB_FILE) c = conn.cursor() c.execute("DELETE FROM wydatki WHERE id = ?", (expense_id,)) conn.commit() conn.close() init_db() # --- CALLBACKS --- if "list_date_picker" not in st.session_state: st.session_state.list_date_picker = date.today() if "cal_date_picker" not in st.session_state: st.session_state.cal_date_picker = date.today() def set_today(): st.session_state.list_date_picker = date.today() st.session_state.cal_date_picker = date.today() # --- DIALOGI --- @st.dialog("Zmień status płatności") def confirm_status_change(row): st.write(f"Pozycja: **{row['nazwa']}**") st.write(f"Kwota: **{row['kwota']:.2f} PLN**") current_status = row['zaplacone'] new_status = not current_status action_text = "Oznaczyć jako ZAPŁACONE? 🟢" if not current_status else "Cofnąć do NIEZAPŁACONE? 🔴" st.markdown(f"### {action_text}") create_next = False next_date = None if not current_status and row['cykliczne']: try: i_ilosc = int(row.get('interwal_ilosc', 1)) except: i_ilosc = 1 i_typ = str(row.get('interwal_typ', 'miesiące')) st.info(f"🔄 To płatność cykliczna: co {i_ilosc} {i_typ}.") create_next = st.checkbox("Utwórz automatycznie następną płatność?", value=True) if create_next: current_date_dt = pd.to_datetime(row['termin']) offset = pd.DateOffset(months=1) if i_typ == 'dni': offset = pd.DateOffset(days=i_ilosc) elif i_typ == 'tygodnie': offset = pd.DateOffset(weeks=i_ilosc) elif i_typ == 'miesiące': offset = pd.DateOffset(months=i_ilosc) elif i_typ == 'lata': offset = pd.DateOffset(years=i_ilosc) next_date_dt = current_date_dt + offset next_date = next_date_dt.date() st.write(f"📅 Data nowej płatności: **{next_date}**") st.divider() c1, c2 = st.columns(2) if c1.button("Nie, anuluj"): st.rerun() if c2.button("Tak, zatwierdź", type="primary"): update_status(int(row['id']), new_status) if create_next and new_status == True and next_date: add_expense(row['nazwa'], row['kategoria'], row['kwota'], next_date, True, i_ilosc, i_typ) st.toast(f"Zaplanowano następną płatność na {next_date}", icon="🚀") st.rerun() @st.dialog("✏️ Edytuj płatność") def edit_expense_dialog(row, categories_list): with st.form(f"edit_form_{row['id']}"): e_name = st.text_input("Nazwa", value=row['nazwa']) cat_index = categories_list.index(row['kategoria']) if row['kategoria'] in categories_list else 0 e_cat = st.selectbox("Kategoria", categories_list, index=cat_index) e_amt = st.number_input("Kwota", value=float(row['kwota']), min_value=0.0, step=10.0) try: e_date_val = pd.to_datetime(row['termin']).date() except: e_date_val = date.today() e_due = st.date_input("Termin", value=e_date_val) if st.form_submit_button("Potwierdź zmiany"): update_expense_details(int(row['id']), e_name, e_cat, e_amt, e_due) st.success("Zapisano!") st.rerun() @st.dialog("🗑️ Potwierdź usunięcie") def delete_expense_dialog(row): st.warning(f"Czy na pewno chcesz usunąć płatność: **{row['nazwa']}** na kwotę **{row['kwota']} PLN**?") c1, c2 = st.columns(2) if c1.button("Nie, anuluj"): st.rerun() if c2.button("Tak, usuń", type="primary"): delete_expense(int(row['id'])) st.success("Usunięto!") st.rerun() @st.dialog("Usuń budżet") def confirm_delete_budget(category): st.warning(f"Czy na pewno chcesz usunąć limit budżetowy dla kategorii: **{category}**?") st.caption("Kategoria oraz przypisane do niej wydatki NIE zostaną usunięte. Usunięty zostanie tylko limit.") col_no, col_yes = st.columns(2) with col_no: if st.button("Anuluj"): st.rerun() with col_yes: if st.button("Tak, usuń", type="primary"): delete_budget_db(category) st.rerun() # --- LOGIKA UI --- categories_list = get_categories() with st.sidebar: st.title("🎛️ Nawigacja") page = st.radio("Widok", ["Lista & Budżet", "Kalendarz", "Cele (Skarbonka)", "Ustawienia"]) st.divider() if page != "Cele (Skarbonka)": st.header("➕ Dodaj wydatek") is_cyclic_mode = st.toggle("Tryb cykliczny", value=False) with st.form("add_form", clear_on_submit=True): name = st.text_input("Nazwa") cat = st.selectbox("Kategoria", categories_list) amt = st.number_input("Kwota", min_value=0.0, step=10.0) due = st.date_input("Termin", value=date.today()) int_qty, int_type = 1, "miesiące" if is_cyclic_mode: st.markdown("---") col_i1, col_i2 = st.columns(2) with col_i1: int_qty = st.number_input("Co ile?", min_value=1, value=1) with col_i2: int_type = st.selectbox("Jednostka", ["dni", "tygodnie", "miesiące", "lata"], index=2) if st.form_submit_button("Zapisz wydatek"): add_expense(name, cat, amt, due, is_cyclic_mode, int_qty, int_type) st.success("Dodano!") st.rerun() else: st.info("Formularz celów znajduje się na stronie głównej.") df = load_data() today = date.today() # --- WIDOK 1: LISTA I BUDŻET --- if page == "Lista & Budżet": st.title("📊 Finanse & Budżet") if not df.empty: col_btn, col_date, col_search = st.columns([1, 2, 3]) with col_btn: st.button("📅 Dzisiaj", on_click=set_today) with col_date: view_date = st.date_input("Pokaż miesiąc:", key="list_date_picker", format="DD.MM.YYYY") with col_search: search_query = st.text_input("🔍 Szukaj płatności (nazwa, kategoria)...", "") start_month = view_date.replace(day=1) _, last_day = calendar.monthrange(view_date.year, view_date.month) end_month = view_date.replace(day=last_day) mask_month_strict = (df['termin'] >= start_month) & (df['termin'] <= end_month) mask_overdue_global = (df['termin'] < today) & (~df['zaplacone']) final_mask = mask_month_strict | mask_overdue_global df_filtered = df[final_mask].copy() # Wyszukiwanie if search_query: df_filtered = df_filtered[ df_filtered['nazwa'].str.contains(search_query, case=False, na=False) | df_filtered['kategoria'].str.contains(search_query, case=False, na=False) ] # KPI mask_unpaid_view = ~df_filtered['zaplacone'] mask_overdue_view = mask_unpaid_view & (df_filtered['termin'] < today) c1, c2 = st.columns(2) c1.metric("Do zapłacenia (Widok)", f"{df_filtered[mask_unpaid_view]['kwota'].sum():.2f} PLN") overdue_cnt = len(df_filtered[mask_overdue_view]) c2.metric("Przeterminowane (Pilne)", f"{df_filtered[mask_overdue_view]['kwota'].sum():.2f} PLN", delta=f"-{overdue_cnt} szt." if overdue_cnt else "Ok", delta_color="normal") st.divider() with st.expander("📉 Realizacja Budżetów (Ten miesiąc)", expanded=True): budgets = get_budgets() if budgets: df_month_only = df[mask_month_strict] spending_by_cat = df_month_only.groupby('kategoria')['kwota'].sum() for cat_name, limit in budgets.items(): spent = spending_by_cat.get(cat_name, 0.0) diff = spent - limit if limit > 0: percent = spent / limit else: percent = 1.0 if spent > 0 else 0.0 if diff > 0: st.markdown(f"**{cat_name}**: {spent:.2f} / {limit:.2f} PLN :red[**⚠️ Przekroczono o {diff:.2f} PLN!**]") st.progress(1.0) else: st.write(f"**{cat_name}**: {spent:.2f} / {limit:.2f} PLN ({percent*100:.0f}%)") st.progress(min(percent, 1.0)) else: st.info("Brak budżetów. Ustaw je w zakładce Ustawienia.") # --- ZAKŁADKI: Tabela vs Podsumowanie --- tab1, tab2 = st.tabs(["📋 Lista Płatności", "📈 Zestawienie Kategorii"]) with tab1: termin_as_dt = pd.to_datetime(df_filtered['termin']) today_as_dt = pd.to_datetime(today) df_filtered['dni_opoznienia'] = (today_as_dt - termin_as_dt).dt.days df_filtered.loc[df_filtered['zaplacone'] | (df_filtered['dni_opoznienia'] <= 0), 'dni_opoznienia'] = None df_view = df_filtered.sort_values(by=['zaplacone', 'termin']) st.caption("👈 Kliknij w kwadracik po lewej stronie wiersza, aby go zaznaczyć i wywołać opcje edycji/usuwania.") event = st.dataframe( df_view, on_select="rerun", selection_mode="single-row", column_config={ "id": None, "interwal_ilosc": None, "interwal_typ": None, "kategoria": "Kategoria", "kwota": st.column_config.NumberColumn(format="%.2f PLN"), "termin": st.column_config.DateColumn(format="YYYY-MM-DD"), "zaplacone": st.column_config.CheckboxColumn(label="Zapłacone?"), "dni_opoznienia": st.column_config.NumberColumn(label="Spóźnienie", format="%d dni"), }, use_container_width=True, hide_index=True ) # Obsługa zaznaczonego wiersza selected_rows = event.selection.rows if selected_rows: selected_idx = selected_rows[0] selected_row = df_view.iloc[selected_idx] st.markdown(f"Wybrana pozycja: **{selected_row['nazwa']}**") col_ed, col_del, col_status, _ = st.columns([1, 1, 2, 3]) if col_ed.button("✏️ Edytuj", key="btn_edit"): edit_expense_dialog(selected_row, categories_list) if col_del.button("🗑️ Usuń", key="btn_delete"): delete_expense_dialog(selected_row) status_label = "⭕ Oznacz jako NIEZAPŁACONE" if selected_row['zaplacone'] else "✅ Oznacz jako ZAPŁACONE" if col_status.button(status_label, key="btn_status", type="primary"): confirm_status_change(selected_row) with tab2: st.subheader("Bieżący miesiąc - Zobowiązania wg kategorii") summary_data = [] for cat in df_filtered['kategoria'].unique(): cat_df = df_filtered[df_filtered['kategoria'] == cat] do_zaplaty = cat_df[~cat_df['zaplacone']]['kwota'].sum() przeterm = cat_df[(~cat_df['zaplacone']) & (cat_df['termin'] < today)]['kwota'].sum() if do_zaplaty > 0 or przeterm > 0: summary_data.append({ "Kategoria": cat, "Do zapłacenia (PLN)": float(do_zaplaty), "Przeterminowane (PLN)": float(przeterm) }) if summary_data: sum_df = pd.DataFrame(summary_data) st.dataframe( sum_df, use_container_width=True, hide_index=True, column_config={ "Do zapłacenia (PLN)": st.column_config.NumberColumn(format="%.2f"), "Przeterminowane (PLN)": st.column_config.NumberColumn(format="%.2f") } ) else: st.success("Wszystko opłacone w tym widoku! 🎉") else: st.info("Brak wpisów.") # --- WIDOK 2: KALENDARZ --- elif page == "Kalendarz": st.title("📅 Kalendarz") col_btn, col_date, _ = st.columns([1, 2, 3]) with col_btn: st.button("📅 Dzisiaj", on_click=set_today, key="cal_today_btn") with col_date: selected_date = st.date_input("Miesiąc", key="cal_date_picker") year, month = selected_date.year, selected_date.month cal = calendar.Calendar(firstweekday=0) month_days = cal.monthdayscalendar(year, month) cols = st.columns(7) for i, d in enumerate(["Pn", "Wt", "Śr", "Cz", "Pt", "Sb", "Nd"]): cols[i].markdown(f"
{d}
", unsafe_allow_html=True) if not df.empty: df['year'] = pd.to_datetime(df['termin']).dt.year df['month'] = pd.to_datetime(df['termin']).dt.month df['day'] = pd.to_datetime(df['termin']).dt.day monthly_data = df[(df['year'] == year) & (df['month'] == month)] else: monthly_data = pd.DataFrame(columns=['day', 'kwota', 'nazwa', 'zaplacone', 'id', 'termin']) for week in month_days: cols = st.columns(7) for i, day_num in enumerate(week): with cols[i]: if day_num == 0: continue is_today = (date(year, month, day_num) == today) day_header = f":red[**{day_num}**]" if is_today else f"**{day_num}**" with st.container(height=140, border=True): st.markdown(day_header) if not monthly_data.empty: day_items = monthly_data[monthly_data['day'] == day_num] for _, row in day_items.iterrows(): kwota = f"{row['kwota']:.0f}" if row['zaplacone']: label, btn_type = f"✅ {kwota} | {row['nazwa']}", "secondary" elif row['termin'] < today: label, btn_type = f"⚠️ {kwota} | {row['nazwa']}", "primary" else: label, btn_type = f"⭕ {kwota} | {row['nazwa']}", "secondary" if st.button(label, key=f"btn_{row['id']}", type=btn_type, use_container_width=True): confirm_status_change(row) # --- WIDOK 3: CELE (SKARBONKA) --- elif page == "Cele (Skarbonka)": st.title("🎯 Moje Cele Finansowe") with st.expander("➕ Dodaj nowy cel", expanded=False): has_deadline = st.toggle("Ustal termin realizacji?", value=True) with st.form("new_goal_form"): c1, c2, c3 = st.columns([2, 1, 1]) with c1: g_name = st.text_input("Nazwa celu (np. Wakacje)") with c2: g_target = st.number_input("Kwota docelowa", min_value=1.0, step=100.0) with c3: g_icon = st.selectbox("Ikona", ["🎯", "🚗", "🏠", "💻", "🌴", "💍", "💰", "🆘"]) g_date = None if has_deadline: g_date = st.date_input("Planowana data", value=date.today()) if st.form_submit_button("Utwórz cel"): add_goal(g_name, g_target, g_date, g_icon) st.success("Dodano cel!") st.rerun() st.divider() goals_df = get_goals() if not goals_df.empty: grid = st.columns(2) for idx, row in goals_df.iterrows(): with grid[idx % 2]: with st.container(border=True): col_head1, col_head2 = st.columns([4, 1]) with col_head1: st.subheader(f"{row['ikona']} {row['nazwa']}") with col_head2: if st.button("🗑️", key=f"del_goal_{row['id']}", help="Usuń ten cel"): delete_goal(row['id']) st.rerun() progress = row['kwota_obecna'] / row['kwota_cel'] if row['kwota_cel'] > 0 else 0 st.progress(min(progress, 1.0)) st.write(f"Uzbierano: **{row['kwota_obecna']:.2f}** / {row['kwota_cel']:.2f} PLN ({progress*100:.1f}%)") missing = row['kwota_cel'] - row['kwota_obecna'] if missing <= 0: st.balloons() st.success("🎉 Cel osiągnięty!") else: st.caption(f"Brakuje: {missing:.2f} PLN") if pd.notnull(row['termin']): days_left = (row['termin'] - today).days if days_left > 0: st.caption(f"📅 Czas do końca: {days_left} dni") else: st.caption(f"📅 Termin minął!") else: st.caption("📅 Termin: Bezterminowo") st.divider() c_in, c_btn = st.columns([2, 1]) with c_in: amount = st.number_input("Kwota", min_value=0.0, step=50.0, key=f"pay_{row['id']}", label_visibility="collapsed") with c_btn: if st.button("Wpłać", key=f"deposit_{row['id']}", type="primary"): update_goal_amount(row['id'], amount) st.rerun() if st.button("Wypłać", key=f"withdraw_{row['id']}"): update_goal_amount(row['id'], -amount) st.rerun() else: st.info("Nie masz jeszcze żadnych celów. Dodaj pierwszy powyżej!") # --- WIDOK 4: USTAWIENIA --- elif page == "Ustawienia": st.title("⚙️ Konfiguracja") col1, col2 = st.columns(2) with col1: st.subheader("Kategorie") new_cat = st.text_input("Nowa kategoria") if st.button("Dodaj kategorię"): if new_cat and add_category_db(new_cat): st.success(f"Dodano: {new_cat}") st.rerun() else: st.error("Błąd lub duplikat.") st.divider() cat_to_del = st.selectbox("Wybierz do usunięcia", categories_list) if st.button("Usuń", type="primary"): delete_category_db(cat_to_del) st.rerun() with col2: st.subheader("Budżety") current_budgets = get_budgets() st.caption("Wybierz kategorię, aby ustawić limit:") selected_cat_budget = st.selectbox("Kategoria", categories_list, key="budget_cat_sel") current_val = current_budgets.get(selected_cat_budget, 0.0) with st.form("budget_form"): new_limit = st.number_input("Limit miesięczny (PLN)", value=current_val, step=100.0, key=f"bud_{selected_cat_budget}") if st.form_submit_button("Zapisz budżet"): set_budget_db(selected_cat_budget, new_limit) st.rerun() st.divider() st.markdown("#### Aktualne limity:") if current_budgets: for c, l in current_budgets.items(): bc1, bc2 = st.columns([3, 1]) with bc1: st.write(f"**{c}**: {l:.2f} PLN") with bc2: if st.button("🗑️", key=f"del_bud_{c}", help="Usuń limit"): confirm_delete_budget(c) else: st.info("Brak zdefiniowanych budżetów.")