Compare commits
10 Commits
8bf37c82b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cd85cc5ae | |||
| 487912a1d6 | |||
| 8fd91f1857 | |||
| 53427e0b4d | |||
| 6ba568ea68 | |||
| d5a2721c5f | |||
| 1667eb5d26 | |||
| 26bfa7fb64 | |||
| 5b752ab7c4 | |||
| e0dadc4b20 |
@@ -26,6 +26,7 @@ main_bp = Blueprint("main", __name__)
|
|||||||
|
|
||||||
|
|
||||||
def _community_account_cards(month, previous_month):
|
def _community_account_cards(month, previous_month):
|
||||||
|
personal_label_map = personal_account_names()
|
||||||
community_accounts = CommunityAccount.query.filter_by(is_active=True).order_by(
|
community_accounts = CommunityAccount.query.filter_by(is_active=True).order_by(
|
||||||
CommunityAccount.sort_order.asc(), CommunityAccount.name.asc()
|
CommunityAccount.sort_order.asc(), CommunityAccount.name.asc()
|
||||||
).all()
|
).all()
|
||||||
@@ -73,9 +74,15 @@ def _community_account_cards(month, previous_month):
|
|||||||
)
|
)
|
||||||
assigned_budget_names = [category.name for category in assigned_categories]
|
assigned_budget_names = [category.name for category in assigned_categories]
|
||||||
delta = current_total - previous_total
|
delta = current_total - previous_total
|
||||||
|
display_name = (
|
||||||
|
f"Auszahlung {personal_label_map.get(community_account.linked_account_slug, community_account.name)}"
|
||||||
|
if community_account.account_type == "personal" and community_account.linked_account_slug
|
||||||
|
else community_account.name
|
||||||
|
)
|
||||||
cards.append(
|
cards.append(
|
||||||
{
|
{
|
||||||
"community_account": community_account,
|
"community_account": community_account,
|
||||||
|
"display_name": display_name,
|
||||||
"current_total": current_total,
|
"current_total": current_total,
|
||||||
"previous_total": previous_total,
|
"previous_total": previous_total,
|
||||||
"delta": delta,
|
"delta": delta,
|
||||||
|
|||||||
+126
-22
@@ -102,6 +102,7 @@ def _resolve_avatar_url(existing: str | None = None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _community_account_totals(month, previous_month, community_accounts, budget_categories):
|
def _community_account_totals(month, previous_month, community_accounts, budget_categories):
|
||||||
|
personal_label_map = personal_account_names()
|
||||||
current_entry_values = {item.entry_id: to_decimal(item.planned_amount) for item in month.entry_values}
|
current_entry_values = {item.entry_id: to_decimal(item.planned_amount) for item in month.entry_values}
|
||||||
previous_entry_values = (
|
previous_entry_values = (
|
||||||
{item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values}
|
{item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values}
|
||||||
@@ -157,9 +158,15 @@ def _community_account_totals(month, previous_month, community_accounts, budget_
|
|||||||
)
|
)
|
||||||
assigned_budget_names = [category.name for category in assigned_categories]
|
assigned_budget_names = [category.name for category in assigned_categories]
|
||||||
delta = current_total - previous_total
|
delta = current_total - previous_total
|
||||||
|
display_name = (
|
||||||
|
f"Auszahlung {personal_label_map.get(community_account.linked_account_slug, community_account.name)}"
|
||||||
|
if community_account.account_type == "personal" and community_account.linked_account_slug
|
||||||
|
else community_account.name
|
||||||
|
)
|
||||||
cards.append(
|
cards.append(
|
||||||
{
|
{
|
||||||
"community_account": community_account,
|
"community_account": community_account,
|
||||||
|
"display_name": display_name,
|
||||||
"current_total": current_total,
|
"current_total": current_total,
|
||||||
"previous_total": previous_total,
|
"previous_total": previous_total,
|
||||||
"delta": delta,
|
"delta": delta,
|
||||||
@@ -170,6 +177,13 @@ def _community_account_totals(month, previous_month, community_accounts, budget_
|
|||||||
return cards
|
return cards
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_distribution_direct_entry(entry_rows: list[dict]) -> dict | None:
|
||||||
|
return next(
|
||||||
|
(entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target),
|
||||||
|
entry_rows[0] if entry_rows else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _distribution_hint_map(allocation_service, month, summary, allocations_by_slug, suggestions_by_slug) -> dict[str, dict]:
|
def _distribution_hint_map(allocation_service, month, summary, allocations_by_slug, suggestions_by_slug) -> dict[str, dict]:
|
||||||
result = {}
|
result = {}
|
||||||
return {
|
return {
|
||||||
@@ -333,7 +347,9 @@ def detail(label: str):
|
|||||||
"distribution_kind": "single" if account.slug in {"sparen", "urlaub", "freizeit"} else None,
|
"distribution_kind": "single" if account.slug in {"sparen", "urlaub", "freizeit"} else None,
|
||||||
"distribution_suggestion_total": suggestion_total,
|
"distribution_suggestion_total": suggestion_total,
|
||||||
"distribution_hint": distribution_hints.get(account.slug),
|
"distribution_hint": distribution_hints.get(account.slug),
|
||||||
"direct_entry": next(
|
"direct_entry": _resolve_distribution_direct_entry(entry_rows)
|
||||||
|
if account.slug in {"sparen", "urlaub", "freizeit"}
|
||||||
|
else next(
|
||||||
(entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target),
|
(entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
@@ -341,6 +357,8 @@ def detail(label: str):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if account.slug in {"sparen", "urlaub", "freizeit"}:
|
if account.slug in {"sparen", "urlaub", "freizeit"}:
|
||||||
|
flattened_entries = [entry for category_card in category_cards for entry in category_card["entries"]]
|
||||||
|
direct_entry = _resolve_distribution_direct_entry(flattened_entries)
|
||||||
distribution_bucket["categories"].append(
|
distribution_bucket["categories"].append(
|
||||||
{
|
{
|
||||||
"category": type(
|
"category": type(
|
||||||
@@ -348,7 +366,7 @@ def detail(label: str):
|
|||||||
(),
|
(),
|
||||||
{"id": account.id * -100, "name": account.name, "account_id": account.id},
|
{"id": account.id * -100, "name": account.name, "account_id": account.id},
|
||||||
)(),
|
)(),
|
||||||
"entries": [entry for category_card in category_cards for entry in category_card["entries"]],
|
"entries": flattened_entries,
|
||||||
"total": account_total,
|
"total": account_total,
|
||||||
"entry_count": sum(category_card["entry_count"] for category_card in category_cards),
|
"entry_count": sum(category_card["entry_count"] for category_card in category_cards),
|
||||||
"dialog_id": f"distribution-dialog-{account.slug}",
|
"dialog_id": f"distribution-dialog-{account.slug}",
|
||||||
@@ -356,15 +374,8 @@ def detail(label: str):
|
|||||||
"distribution_suggestion_total": distribution_hints.get(account.slug, {}).get("remaining_amount", Decimal("0.00")),
|
"distribution_suggestion_total": distribution_hints.get(account.slug, {}).get("remaining_amount", Decimal("0.00")),
|
||||||
"distribution_hint": distribution_hints.get(account.slug),
|
"distribution_hint": distribution_hints.get(account.slug),
|
||||||
"distribution_account_slug": account.slug,
|
"distribution_account_slug": account.slug,
|
||||||
"direct_entry": next(
|
"direct_entry": direct_entry,
|
||||||
(
|
"allow_new_entries": direct_entry is None,
|
||||||
category_card.get("direct_entry")
|
|
||||||
for category_card in category_cards
|
|
||||||
if category_card.get("direct_entry") is not None
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
"allow_new_entries": False,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
distribution_bucket["total"] += account_total
|
distribution_bucket["total"] += account_total
|
||||||
@@ -674,10 +685,24 @@ def create_category(label: str):
|
|||||||
flash("Bitte einen Bereich für die Kategorie wählen.", "danger")
|
flash("Bitte einen Bereich für die Kategorie wählen.", "danger")
|
||||||
return _dialog_redirect(month.label, request.form.get("return_dialog") or None)
|
return _dialog_redirect(month.label, request.form.get("return_dialog") or None)
|
||||||
account_id = account.id
|
account_id = account.id
|
||||||
existing = Category.query.filter_by(account_id=account_id, slug=slugify(name)).first()
|
slug = slugify(name)
|
||||||
|
existing = Category.query.filter_by(account_id=account_id, slug=slug).first()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
flash("Kategorie existiert bereits.", "info")
|
if existing.is_active:
|
||||||
return redirect(url_for("planning.detail", label=label))
|
flash("Kategorie existiert bereits.", "info")
|
||||||
|
return redirect(url_for("planning.detail", label=label))
|
||||||
|
existing.name = name
|
||||||
|
existing.slug = slug
|
||||||
|
existing.description = request.form.get("description", "").strip() or None
|
||||||
|
existing.community_account_id = (
|
||||||
|
int(request.form["community_account_id"])
|
||||||
|
if account.slug == "gemeinschaftskonto" and request.form.get("community_account_id")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
existing.is_active = True
|
||||||
|
db.session.commit()
|
||||||
|
flash("Kategorie wiederhergestellt.", "success")
|
||||||
|
return _dialog_redirect(month.label, _category_dialog_id(existing))
|
||||||
last_sort = (
|
last_sort = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
select(Category.sort_order)
|
select(Category.sort_order)
|
||||||
@@ -695,7 +720,7 @@ def create_category(label: str):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
name=name,
|
name=name,
|
||||||
slug=slugify(name),
|
slug=slug,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
sort_order=last_sort + 1,
|
sort_order=last_sort + 1,
|
||||||
)
|
)
|
||||||
@@ -740,7 +765,7 @@ def create_entry(label: str):
|
|||||||
category_id = request.form.get("category_id")
|
category_id = request.form.get("category_id")
|
||||||
category = None
|
category = None
|
||||||
if category_id:
|
if category_id:
|
||||||
category = Category.query.get_or_404(int(category_id))
|
category = Category.query.get_or_404(int(category_id))
|
||||||
elif category_name and account_id:
|
elif category_name and account_id:
|
||||||
category = Category.query.filter_by(
|
category = Category.query.filter_by(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@@ -765,14 +790,82 @@ def create_entry(label: str):
|
|||||||
)
|
)
|
||||||
db.session.add(category)
|
db.session.add(category)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
elif not category.is_active:
|
||||||
|
category.is_active = True
|
||||||
|
db.session.flush()
|
||||||
if category is None:
|
if category is None:
|
||||||
flash("Bitte Konto und Kategorie angeben.", "danger")
|
flash("Bitte Konto und Kategorie angeben.", "danger")
|
||||||
return redirect(url_for("planning.detail", label=label))
|
return redirect(url_for("planning.detail", label=label))
|
||||||
slug = slugify(request.form.get("slug", "") or name)
|
slug = slugify(request.form.get("slug", "") or name)
|
||||||
existing = Entry.query.filter_by(category_id=category.id, slug=slug).first()
|
existing = Entry.query.filter_by(category_id=category.id, slug=slug).first()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
flash("Eintrag existiert bereits.", "info")
|
if existing.is_active:
|
||||||
return redirect(url_for("planning.detail", label=label))
|
flash("Eintrag existiert bereits.", "info")
|
||||||
|
return redirect(url_for("planning.detail", label=label))
|
||||||
|
existing.name = name
|
||||||
|
existing.slug = slug
|
||||||
|
existing.description = request.form.get("description", "").strip() or None
|
||||||
|
existing.default_amount = to_decimal(request.form.get("default_amount", "0"))
|
||||||
|
existing.amount_type = request.form.get("amount_type", "fixed")
|
||||||
|
existing.benefit_scope = request.form.get("benefit_scope", "all-users")
|
||||||
|
existing.is_allocation_target = request.form.get("is_allocation_target") == "on"
|
||||||
|
existing.is_active = True
|
||||||
|
entry = existing
|
||||||
|
db.session.flush()
|
||||||
|
months = Month.query.order_by(Month.year.asc(), Month.month.asc()).all()
|
||||||
|
current_note = request.form.get("note", "").strip() or None
|
||||||
|
current_amount = _resolve_monthly_amount(request.form)
|
||||||
|
existing_values = {item.month_id: item for item in entry.monthly_values}
|
||||||
|
for target_month in months:
|
||||||
|
value = existing_values.get(target_month.id)
|
||||||
|
if value is None:
|
||||||
|
db.session.add(
|
||||||
|
MonthlyEntryValue(
|
||||||
|
month_id=target_month.id,
|
||||||
|
entry_id=entry.id,
|
||||||
|
planned_amount=current_amount if target_month.id == month.id else entry.default_amount,
|
||||||
|
note=current_note if target_month.id == month.id else None,
|
||||||
|
created_by=current_user.id,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif target_month.id == month.id:
|
||||||
|
value.planned_amount = current_amount
|
||||||
|
value.note = current_note
|
||||||
|
value.updated_by = current_user.id
|
||||||
|
selected_participants = {
|
||||||
|
int(item) for item in request.form.getlist("participant_ids") if item.strip()
|
||||||
|
}
|
||||||
|
existing_rules = {rule.participant_id: rule for rule in entry.share_rules}
|
||||||
|
for participant_id, rule in list(existing_rules.items()):
|
||||||
|
if participant_id not in selected_participants:
|
||||||
|
db.session.delete(rule)
|
||||||
|
for participant_id in selected_participants:
|
||||||
|
rule = existing_rules.get(participant_id)
|
||||||
|
if rule is None:
|
||||||
|
db.session.add(
|
||||||
|
EntryShareRule(
|
||||||
|
entry_id=entry.id,
|
||||||
|
participant_id=participant_id,
|
||||||
|
share_type="equal",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rule.share_type = "equal"
|
||||||
|
rule.share_value = None
|
||||||
|
current_app.extensions["saldo.month_service"].sync_distribution_allocation_from_entry(
|
||||||
|
month,
|
||||||
|
entry,
|
||||||
|
mark_manual=True,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
current_app.extensions["saldo.month_service"].refresh_suggestions(
|
||||||
|
month, reason="Eintrag wurde wiederhergestellt"
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Eintrag wiederhergestellt.", "success")
|
||||||
|
return_dialog = request.form.get("return_dialog") or _category_dialog_id(category)
|
||||||
|
return _dialog_redirect(label, return_dialog)
|
||||||
last_sort = (
|
last_sort = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
select(Entry.sort_order)
|
select(Entry.sort_order)
|
||||||
@@ -865,10 +958,21 @@ def create_community_account(label: str):
|
|||||||
if not name:
|
if not name:
|
||||||
flash("Bitte einen Namen für das Gemeinschaftskonto angeben.", "danger")
|
flash("Bitte einen Namen für das Gemeinschaftskonto angeben.", "danger")
|
||||||
return _dialog_redirect(label, "community-account-create-dialog")
|
return _dialog_redirect(label, "community-account-create-dialog")
|
||||||
existing = CommunityAccount.query.filter_by(slug=slugify(name)).first()
|
slug = slugify(name)
|
||||||
|
existing = CommunityAccount.query.filter_by(slug=slug).first()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
flash("Gemeinschaftskonto existiert bereits.", "info")
|
if existing.is_active:
|
||||||
return _dialog_redirect(label, "community-account-create-dialog")
|
flash("Gemeinschaftskonto existiert bereits.", "info")
|
||||||
|
return _dialog_redirect(label, "community-account-create-dialog")
|
||||||
|
existing.name = name
|
||||||
|
existing.slug = slug
|
||||||
|
existing.account_type = "shared"
|
||||||
|
existing.linked_account_slug = None
|
||||||
|
existing.description = request.form.get("description", "").strip() or None
|
||||||
|
existing.is_active = True
|
||||||
|
db.session.commit()
|
||||||
|
flash("Gemeinschaftskonto wiederhergestellt.", "success")
|
||||||
|
return _dialog_redirect(label)
|
||||||
last_sort = (
|
last_sort = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
select(CommunityAccount.sort_order)
|
select(CommunityAccount.sort_order)
|
||||||
@@ -880,7 +984,7 @@ def create_community_account(label: str):
|
|||||||
)
|
)
|
||||||
community_account = CommunityAccount(
|
community_account = CommunityAccount(
|
||||||
name=name,
|
name=name,
|
||||||
slug=slugify(name),
|
slug=slug,
|
||||||
account_type="shared",
|
account_type="shared",
|
||||||
description=request.form.get("description", "").strip() or None,
|
description=request.form.get("description", "").strip() or None,
|
||||||
sort_order=last_sort + 1,
|
sort_order=last_sort + 1,
|
||||||
|
|||||||
+46
-11
@@ -13,6 +13,7 @@ from app.models import (
|
|||||||
MonthlyIncome,
|
MonthlyIncome,
|
||||||
NotificationPreference,
|
NotificationPreference,
|
||||||
User,
|
User,
|
||||||
|
to_decimal,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -203,6 +204,44 @@ def _deactivate_placeholder_categories() -> None:
|
|||||||
category.community_account_id = None
|
category.community_account_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_existing_standard_structure() -> None:
|
||||||
|
for account_slug, account_data in ACCOUNT_TREE.items():
|
||||||
|
account = Account.query.filter_by(slug=account_slug).first()
|
||||||
|
if account is None:
|
||||||
|
continue
|
||||||
|
for category_slug, entries in account_data["categories"].items():
|
||||||
|
category = Category.query.filter_by(account_id=account.id, slug=category_slug).first()
|
||||||
|
legacy_slug = LEGACY_CATEGORY_SLUGS.get((account_slug, category_slug))
|
||||||
|
if category is None and legacy_slug:
|
||||||
|
category = Category.query.filter_by(account_id=account.id, slug=legacy_slug).first()
|
||||||
|
if category is None:
|
||||||
|
continue
|
||||||
|
category.is_active = True
|
||||||
|
for entry_name in entries:
|
||||||
|
entry_slug = slugify(entry_name)
|
||||||
|
entry = Entry.query.filter_by(category_id=category.id, slug=entry_slug).first()
|
||||||
|
legacy_entry_name = LEGACY_ENTRY_NAMES.get((account_slug, category_slug, entry_name))
|
||||||
|
if entry is None and legacy_entry_name:
|
||||||
|
entry = Entry.query.filter_by(
|
||||||
|
category_id=category.id,
|
||||||
|
slug=slugify(legacy_entry_name),
|
||||||
|
).first()
|
||||||
|
if entry is not None:
|
||||||
|
entry.is_active = True
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_existing_budget_visibility() -> None:
|
||||||
|
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
|
||||||
|
if gemeinschaft is None:
|
||||||
|
return
|
||||||
|
for category in gemeinschaft.categories:
|
||||||
|
if category.is_active:
|
||||||
|
continue
|
||||||
|
active_entries = [entry for entry in category.entries if entry.is_active]
|
||||||
|
if active_entries:
|
||||||
|
category.is_active = True
|
||||||
|
|
||||||
|
|
||||||
def seed_data(include_example_entries: bool = False) -> None:
|
def seed_data(include_example_entries: bool = False) -> None:
|
||||||
# Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
|
# Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
|
||||||
# Beispiel-Personen oder vorausgefüllte Monatsdaten.
|
# Beispiel-Personen oder vorausgefüllte Monatsdaten.
|
||||||
@@ -392,18 +431,14 @@ def seed_data(include_example_entries: bool = False) -> None:
|
|||||||
"Person 1",
|
"Person 1",
|
||||||
"Person 2",
|
"Person 2",
|
||||||
}
|
}
|
||||||
|
for category in gemeinschaft.categories:
|
||||||
|
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
|
||||||
|
category.is_active = False
|
||||||
|
elif category.community_account_id is None:
|
||||||
|
category.community_account_id = community_accounts["hauptkonto"].id
|
||||||
else:
|
else:
|
||||||
_deactivate_placeholder_categories()
|
_restore_existing_standard_structure()
|
||||||
_deactivate_placeholder_entries()
|
_restore_existing_budget_visibility()
|
||||||
for category in gemeinschaft.categories:
|
|
||||||
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
|
|
||||||
category.is_active = False
|
|
||||||
elif category.community_account_id is None:
|
|
||||||
category.community_account_id = community_accounts["hauptkonto"].id
|
|
||||||
|
|
||||||
if not include_example_entries:
|
|
||||||
_deactivate_placeholder_categories()
|
|
||||||
_deactivate_placeholder_entries()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|||||||
+48
-2
@@ -717,11 +717,15 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
|
|||||||
|
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto 12px 12px 12px;
|
left: max(8px, env(safe-area-inset-left));
|
||||||
|
right: max(8px, env(safe-area-inset-right));
|
||||||
|
bottom: calc(8px + env(safe-area-inset-bottom));
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 0 auto;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
@@ -733,10 +737,18 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
|
|||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.82rem;
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.1;
|
||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a span {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-theme-toggle {
|
.mobile-theme-toggle {
|
||||||
@@ -784,6 +796,40 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.content {
|
||||||
|
padding: 18px 14px 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a {
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
padding: 7px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a span {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 2.2em;
|
||||||
|
text-wrap: balance;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a .ui-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.planning-hero-strong {
|
.planning-hero-strong {
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,14 +76,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<nav class="bottom-nav">
|
<nav class="bottom-nav">
|
||||||
<a href="{{ url_for('main.index') }}"><img src="{{ url_for('static', filename='icons/house.svg') }}" alt="" class="ui-icon">Übersicht</a>
|
<a href="{{ url_for('main.index') }}" aria-label="Übersicht" title="Übersicht"><img src="{{ url_for('static', filename='icons/house.svg') }}" alt="" class="ui-icon"><span>Übersicht</span></a>
|
||||||
<a href="{{ url_for('planning.current') }}"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon">Planung</a>
|
<a href="{{ url_for('planning.current') }}" aria-label="Planung" title="Planung"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon"><span>Planung</span></a>
|
||||||
<a href="{{ url_for('months.index') }}"><img src="{{ url_for('static', filename='icons/calendar.svg') }}" alt="" class="ui-icon">Monate</a>
|
<a href="{{ url_for('months.index') }}" aria-label="Monate" title="Monate"><img src="{{ url_for('static', filename='icons/calendar.svg') }}" alt="" class="ui-icon"><span>Monate</span></a>
|
||||||
<a href="{{ url_for('main.analytics') }}"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon">Auswertungen</a>
|
<a href="{{ url_for('main.analytics') }}" aria-label="Auswertungen" title="Auswertungen"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon"><span>Auswertungen</span></a>
|
||||||
{% if current_user.is_admin() %}
|
{% if current_user.is_admin() %}
|
||||||
<a href="{{ url_for('admin.index') }}"><img src="{{ url_for('static', filename='icons/sliders.svg') }}" alt="" class="ui-icon">Optionen</a>
|
<a href="{{ url_for('admin.index') }}" aria-label="Optionen" title="Optionen"><img src="{{ url_for('static', filename='icons/sliders.svg') }}" alt="" class="ui-icon"><span>Optionen</span></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" data-enable-push><img src="{{ url_for('static', filename='icons/bell.svg') }}" alt="" class="ui-icon">Push</a>
|
<a href="#" data-enable-push aria-label="Push" title="Push"><img src="{{ url_for('static', filename='icons/bell.svg') }}" alt="" class="ui-icon"><span>Push</span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
{% if card.is_read_only %}
|
{% if card.is_read_only %}
|
||||||
<div class="summary-category-card summary-static-card community-account-card">
|
<div class="summary-category-card summary-static-card community-account-card">
|
||||||
<div class="summary-card-head">
|
<div class="summary-card-head">
|
||||||
<strong>{{ card.community_account.name }}</strong>
|
<strong>{{ card.display_name }}</strong>
|
||||||
<span class="icon-label muted-label">Nur Anzeige</span>
|
<span class="icon-label muted-label">Nur Anzeige</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card-meta">
|
<div class="summary-card-meta">
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<button type="button" class="summary-category-card community-account-card" data-open-dialog="community-account-item-{{ card.community_account.id }}">
|
<button type="button" class="summary-category-card community-account-card" data-open-dialog="community-account-item-{{ card.community_account.id }}">
|
||||||
<div class="summary-card-head">
|
<div class="summary-card-head">
|
||||||
<strong>{{ card.community_account.name }}</strong>
|
<strong>{{ card.display_name }}</strong>
|
||||||
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
|
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card-meta">
|
<div class="summary-card-meta">
|
||||||
@@ -226,10 +226,25 @@
|
|||||||
<div class="dialog-action-row dialog-action-spread">
|
<div class="dialog-action-row dialog-action-spread">
|
||||||
<button class="ghost-button small-button" type="submit">Speichern</button>
|
<button class="ghost-button small-button" type="submit">Speichern</button>
|
||||||
{% if month.incomes|length > 1 %}
|
{% if month.incomes|length > 1 %}
|
||||||
<button class="ghost-button danger-button small-button" type="submit" formaction="{{ url_for('planning.delete_income', label=month.label, income_id=income.id) }}">Löschen</button>
|
<button class="ghost-button danger-button small-button" type="button" data-open-dialog="confirm-delete-income-{{ income.id }}">Löschen</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% if month.incomes|length > 1 %}
|
||||||
|
<dialog id="confirm-delete-income-{{ income.id }}" class="app-dialog confirm-dialog">
|
||||||
|
<form method="dialog" class="dialog-close-row">
|
||||||
|
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||||
|
</form>
|
||||||
|
<div class="stack-form">
|
||||||
|
<h3>Einkommenszeile wirklich löschen?</h3>
|
||||||
|
<p class="muted">`{{ income.label }}` wird aus diesem Monat entfernt.</p>
|
||||||
|
<form method="post" action="{{ url_for('planning.delete_income', label=month.label, income_id=income.id) }}" class="dialog-action-row dialog-action-spread">
|
||||||
|
<button class="ghost-button" type="button" data-open-dialog="income-dialog">Zurück</button>
|
||||||
|
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('planning.create_income', label=month.label) }}" class="soft-form-section stack-form">
|
<form method="post" action="{{ url_for('planning.create_income', label=month.label) }}" class="soft-form-section stack-form">
|
||||||
@@ -455,7 +470,7 @@
|
|||||||
<small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small>
|
<small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small>
|
||||||
</div>
|
</div>
|
||||||
{% if category_data.allow_new_entries %}
|
{% if category_data.allow_new_entries %}
|
||||||
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-entry" data-account-id="{{ category_data.category.account_id }}" data-category-name="{{ category_data.category.name }}" data-return-dialog="{{ category_data.dialog_id }}" data-area="{{ 'budget' if category_data.category.account.slug == 'gemeinschaftskonto' else 'distribution' }}">
|
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-entry" data-account-id="{{ category_data.category.account_id }}" data-category-name="{{ category_data.category.name }}" data-return-dialog="{{ category_data.dialog_id }}" data-area="{{ 'budget' if account_data.account.slug == 'gemeinschaftskonto' else 'distribution' }}">
|
||||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||||
Eintrag
|
Eintrag
|
||||||
</button>
|
</button>
|
||||||
@@ -598,8 +613,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('planning.delete_category', label=month.label, category_id=category_data.category.id) }}">
|
<button class="ghost-button danger-button" type="button" data-open-dialog="confirm-delete-category-{{ category_data.category.id }}">Kategorie löschen</button>
|
||||||
<button class="ghost-button danger-button" type="submit">Kategorie löschen</button>
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="confirm-delete-category-{{ category_data.category.id }}" class="app-dialog confirm-dialog">
|
||||||
|
<form method="dialog" class="dialog-close-row">
|
||||||
|
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||||
|
</form>
|
||||||
|
<div class="stack-form">
|
||||||
|
<h3>Kategorie wirklich löschen?</h3>
|
||||||
|
<p class="muted">`{{ category_data.category.name }}` wird ausgeblendet und ihre Einträge werden mit ausgeblendet.</p>
|
||||||
|
<form method="post" action="{{ url_for('planning.delete_category', label=month.label, category_id=category_data.category.id) }}" class="dialog-action-row dialog-action-spread">
|
||||||
|
<button class="ghost-button" type="button" data-open-dialog="{{ category_data.dialog_id }}">Zurück</button>
|
||||||
|
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@ def active_users() -> list[User]:
|
|||||||
def personal_users() -> list[User]:
|
def personal_users() -> list[User]:
|
||||||
users = active_users()
|
users = active_users()
|
||||||
non_admin = [user for user in users if not user.is_admin()]
|
non_admin = [user for user in users if not user.is_admin()]
|
||||||
return (non_admin or users)[:2]
|
admin_users = [user for user in users if user.is_admin()]
|
||||||
|
return (non_admin + admin_users)[:2]
|
||||||
|
|
||||||
|
|
||||||
def personal_account_names() -> dict[str, str]:
|
def personal_account_names() -> dict[str, str]:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import Account, AllocationSuggestion, Month
|
from app.models import Account, AllocationSuggestion, Month, User
|
||||||
from app.utils.users import personal_account_names
|
from app.utils.users import personal_account_names
|
||||||
|
|
||||||
|
|
||||||
@@ -169,3 +169,14 @@ def test_seeded_distribution_entries_are_marked_as_allocation_targets(app):
|
|||||||
assert target_entries["Freizeitbudget"] is True
|
assert target_entries["Freizeitbudget"] is True
|
||||||
assert target_entries["Person 1"] is True
|
assert target_entries["Person 1"] is True
|
||||||
assert target_entries["Person 2"] is True
|
assert target_entries["Person 2"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_personal_account_names_fill_with_admin_if_only_one_editor_is_active(app):
|
||||||
|
editor_b = User.query.filter_by(username="mitglied2").first()
|
||||||
|
editor_b.is_active = False
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
personal_labels = personal_account_names()
|
||||||
|
|
||||||
|
assert personal_labels["persoenlich-flo"] == "Person A"
|
||||||
|
assert personal_labels["persoenlich-desi"] == "Admin"
|
||||||
|
|||||||
+79
-2
@@ -101,14 +101,38 @@ def test_distribution_dialog_shows_direct_budget_form(logged_in_client):
|
|||||||
assert b"Sparkonto" in response.data
|
assert b"Sparkonto" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribution_dialog_keeps_direct_budget_form_when_target_flag_is_missing(logged_in_client):
|
||||||
|
month = Month.query.filter_by(label="2026-04").first()
|
||||||
|
sparen_value = next(item for item in month.entry_values if item.entry.name == "Sparziel")
|
||||||
|
sparen_value.entry.is_allocation_target = False
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = logged_in_client.get("/planning/2026-04")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Budget direkt anpassen" in response.data
|
||||||
|
assert b"Monatliches Budget" in response.data
|
||||||
|
|
||||||
|
|
||||||
def test_planning_shows_budgets_and_community_accounts(logged_in_client):
|
def test_planning_shows_budgets_and_community_accounts(logged_in_client):
|
||||||
response = logged_in_client.get("/planning/2026-04")
|
response = logged_in_client.get("/planning/2026-04")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"Budgets" in response.data
|
assert b"Budgets" in response.data
|
||||||
assert b"Gemeinschaftskonten" in response.data
|
assert b"Gemeinschaftskonten" in response.data
|
||||||
assert b"Privatkonto 1" in response.data
|
assert b"Auszahlung Person A" in response.data
|
||||||
assert b"Privatkonto 2" in response.data
|
assert b"Auszahlung Person B" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_planning_delete_actions_render_app_confirmation_dialogs(logged_in_client):
|
||||||
|
response = logged_in_client.get("/planning/2026-04")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"confirm-delete-income-" in response.data
|
||||||
|
assert b"confirm-delete-category-" in response.data
|
||||||
|
assert b"confirm-delete-community-account-" in response.data
|
||||||
|
assert b"confirm-delete-entry-" in response.data
|
||||||
|
assert b"data-confirm-submit" not in response.data
|
||||||
|
|
||||||
|
|
||||||
def test_community_account_can_assign_budget_categories(logged_in_client):
|
def test_community_account_can_assign_budget_categories(logged_in_client):
|
||||||
@@ -130,6 +154,59 @@ def test_community_account_can_assign_budget_categories(logged_in_client):
|
|||||||
assert category.community_account_id == community_account.id
|
assert category.community_account_id == community_account.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleted_category_can_be_created_again(logged_in_client):
|
||||||
|
create_response = logged_in_client.post(
|
||||||
|
"/planning/2026-04/categories",
|
||||||
|
data={"name": "Testbudget", "area": "budget"},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 302
|
||||||
|
|
||||||
|
category = Category.query.filter_by(slug="testbudget").first()
|
||||||
|
original_id = category.id
|
||||||
|
|
||||||
|
delete_response = logged_in_client.post(f"/planning/2026-04/categories/{category.id}/delete")
|
||||||
|
assert delete_response.status_code == 302
|
||||||
|
|
||||||
|
recreate_response = logged_in_client.post(
|
||||||
|
"/planning/2026-04/categories",
|
||||||
|
data={"name": "Testbudget", "area": "budget"},
|
||||||
|
)
|
||||||
|
|
||||||
|
restored = Category.query.filter_by(slug="testbudget").first()
|
||||||
|
|
||||||
|
assert recreate_response.status_code == 302
|
||||||
|
assert restored.id == original_id
|
||||||
|
assert restored.is_active is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleted_community_account_can_be_created_again(logged_in_client):
|
||||||
|
create_response = logged_in_client.post(
|
||||||
|
"/planning/2026-04/community-accounts",
|
||||||
|
data={"name": "Testkonto", "description": ""},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 302
|
||||||
|
|
||||||
|
community_account = CommunityAccount.query.filter_by(slug="testkonto").first()
|
||||||
|
original_id = community_account.id
|
||||||
|
|
||||||
|
delete_response = logged_in_client.post(
|
||||||
|
f"/planning/2026-04/community-accounts/{community_account.id}/delete"
|
||||||
|
)
|
||||||
|
assert delete_response.status_code == 302
|
||||||
|
|
||||||
|
recreate_response = logged_in_client.post(
|
||||||
|
"/planning/2026-04/community-accounts",
|
||||||
|
data={"name": "Testkonto", "description": "Wieder da"},
|
||||||
|
)
|
||||||
|
|
||||||
|
restored = CommunityAccount.query.filter_by(slug="testkonto").first()
|
||||||
|
|
||||||
|
assert recreate_response.status_code == 302
|
||||||
|
assert restored.id == original_id
|
||||||
|
assert restored.is_active is True
|
||||||
|
assert restored.description == "Wieder da"
|
||||||
|
|
||||||
|
|
||||||
def test_community_account_rejects_budget_assigned_to_other_account(logged_in_client):
|
def test_community_account_rejects_budget_assigned_to_other_account(logged_in_client):
|
||||||
primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
||||||
secondary_response = logged_in_client.post(
|
secondary_response = logged_in_client.post(
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Account, Category, Entry
|
||||||
|
from app.seed import seed_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_restores_hidden_budget_categories_with_active_entries(app):
|
||||||
|
budget_account = Account.query.filter_by(slug="gemeinschaftskonto").first()
|
||||||
|
category = Category(
|
||||||
|
account_id=budget_account.id,
|
||||||
|
name="Legacy Budget",
|
||||||
|
slug="legacy-budget",
|
||||||
|
is_active=False,
|
||||||
|
sort_order=999,
|
||||||
|
)
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
Entry(
|
||||||
|
category_id=category.id,
|
||||||
|
name="Legacy Eintrag",
|
||||||
|
slug="legacy-eintrag",
|
||||||
|
default_amount=0,
|
||||||
|
amount_type="fixed",
|
||||||
|
benefit_scope="all-users",
|
||||||
|
is_active=True,
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
seed_data()
|
||||||
|
db.session.expire_all()
|
||||||
|
|
||||||
|
restored = Category.query.filter_by(account_id=budget_account.id, slug="legacy-budget").first()
|
||||||
|
assert restored is not None
|
||||||
|
assert restored.is_active is True
|
||||||
Reference in New Issue
Block a user