first commit
This commit is contained in:
BIN
.Dockerfile.un~
Normal file
BIN
.Dockerfile.un~
Normal file
Binary file not shown.
BIN
.requirements.txt.un~
Normal file
BIN
.requirements.txt.un~
Normal file
Binary file not shown.
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Tworzymy katalog na dane
|
||||||
|
RUN mkdir -p data
|
||||||
|
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
0
Dockerfile~
Normal file
0
Dockerfile~
Normal file
574
app.py
Normal file
574
app.py
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
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("""
|
||||||
|
<style>
|
||||||
|
div[data-testid="stVerticalBlock"] > div[style*="flex-direction: column;"] > div[data-testid="stVerticalBlock"] {
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
div[data-testid="stColumn"] {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
section[data-testid="stMain"] div[data-testid="stButton"] button {
|
||||||
|
min-height: 25px !important;
|
||||||
|
height: auto !important;
|
||||||
|
padding-top: 4px !important;
|
||||||
|
padding-bottom: 4px !important;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
margin-top: 2px !important;
|
||||||
|
}
|
||||||
|
section[data-testid="stMain"] div[data-testid="stButton"] button p {
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
}
|
||||||
|
div[key="cal_today_btn"] button {
|
||||||
|
margin-top: 28px !important;
|
||||||
|
}
|
||||||
|
.budget-ok { color: green; font-weight: bold; }
|
||||||
|
.budget-warn { color: orange; font-weight: bold; }
|
||||||
|
.budget-over { color: red; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
""", 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 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 "view_date_picker" not in st.session_state:
|
||||||
|
st.session_state.view_date_picker = date.today()
|
||||||
|
|
||||||
|
def set_today():
|
||||||
|
st.session_state.view_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()
|
||||||
|
if st.button("Zatwierdź zmianę", type="primary"):
|
||||||
|
update_status(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("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, _ = 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="view_date_picker", format="DD.MM.YYYY")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# --- NOWA LOGIKA WYŚWIETLANIA BUDŻETU ---
|
||||||
|
diff = spent - limit
|
||||||
|
|
||||||
|
# Jeśli budżet jest 0 lub ujemny (błąd), traktujemy jako 100% przekroczenia
|
||||||
|
if limit > 0:
|
||||||
|
percent = spent / limit
|
||||||
|
else:
|
||||||
|
percent = 1.0 if spent > 0 else 0.0
|
||||||
|
|
||||||
|
if diff > 0:
|
||||||
|
# PRZEKROCZENIE - CZERWONY TEKST I PEŁNY PASEK
|
||||||
|
st.markdown(f"**{cat_name}**: {spent:.2f} / {limit:.2f} PLN :red[**⚠️ Przekroczono o {diff:.2f} PLN!**]")
|
||||||
|
st.progress(1.0)
|
||||||
|
else:
|
||||||
|
# W NORMIE
|
||||||
|
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.")
|
||||||
|
|
||||||
|
# Tabela
|
||||||
|
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'])
|
||||||
|
editor_key = f"main_editor_{view_date.strftime('%Y%m')}"
|
||||||
|
|
||||||
|
edited_df = st.data_editor(
|
||||||
|
df_view,
|
||||||
|
column_config={
|
||||||
|
"id": None, "interwal_ilosc": None, "interwal_typ": None,
|
||||||
|
"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"),
|
||||||
|
},
|
||||||
|
disabled=["id", "nazwa", "kategoria", "kwota", "termin", "cykliczne", "dni_opoznienia", "interwal_ilosc", "interwal_typ"],
|
||||||
|
hide_index=True, use_container_width=True, key=editor_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync
|
||||||
|
old_status = dict(zip(df_filtered['id'], df_filtered['zaplacone']))
|
||||||
|
new_status = dict(zip(edited_df['id'], edited_df['zaplacone']))
|
||||||
|
changes_made = False
|
||||||
|
for eid, paid in new_status.items():
|
||||||
|
if old_status.get(eid) != paid:
|
||||||
|
update_status(eid, paid)
|
||||||
|
changes_made = True
|
||||||
|
row = df_filtered[df_filtered['id'] == eid].iloc[0]
|
||||||
|
if paid 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'))
|
||||||
|
|
||||||
|
current_date_dt = pd.to_datetime(row['termin'])
|
||||||
|
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)
|
||||||
|
else: offset = pd.DateOffset(months=1)
|
||||||
|
next_date = (current_date_dt + offset).date()
|
||||||
|
add_expense(row['nazwa'], row['kategoria'], row['kwota'], next_date, True, i_ilosc, i_typ)
|
||||||
|
st.toast(f"Automatycznie dodano kolejną płatność: {next_date}")
|
||||||
|
if changes_made: st.rerun()
|
||||||
|
|
||||||
|
with st.expander("🗑️ Usuwanie"):
|
||||||
|
options = {f"{row['nazwa']} ({row['kwota']} zł)": row['id'] for _, row in df_filtered.iterrows()}
|
||||||
|
selected = st.multiselect("Wybierz pozycje:", list(options.keys()))
|
||||||
|
if st.button("Usuń zaznaczone") and selected:
|
||||||
|
for label in selected: delete_expense(options[label])
|
||||||
|
st.rerun()
|
||||||
|
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="view_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"<div style='text-align:center'><b>{d}</b></div>", 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.")
|
||||||
BIN
data/finanse.db
Normal file
BIN
data/finanse.db
Normal file
Binary file not shown.
7
docker-compose.override.yml
Normal file
7
docker-compose.override.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
finanse-app:
|
||||||
|
labels:
|
||||||
|
net.unraid.docker.managed: 'composeman'
|
||||||
|
net.unraid.docker.icon: ''
|
||||||
|
net.unraid.docker.webui: ''
|
||||||
|
net.unraid.docker.shell: ''
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
finanse-app:
|
||||||
|
container_name: finanse-domowe
|
||||||
|
# 'build: .' mówi Unraidowi, żeby zbudował obraz z Dockerfile w tym samym folderze
|
||||||
|
build: .
|
||||||
|
image: finanse-domowe:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8501:8501"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=${TIMEZONE}
|
||||||
|
volumes:
|
||||||
|
# Mapowanie folderu data, aby baza danych przetrwała restarty
|
||||||
|
# Ścieżka ./data oznacza folder 'data' w katalogu, gdzie leży plik compose
|
||||||
|
- ./data:/app/data
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
streamlit
|
||||||
|
pandas
|
||||||
0
requirements.txt~
Normal file
0
requirements.txt~
Normal file
Reference in New Issue
Block a user