init
This commit is contained in:
commit
ac93e6074b
38 changed files with 7162 additions and 0 deletions
265
src/common_cents/screens/_spending_bulk_form.py
Normal file
265
src/common_cents/screens/_spending_bulk_form.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
from datetime import date
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import Button, Checkbox, Header, Input, Label, Static
|
||||
|
||||
from common_cents.screens._base import AppScreen
|
||||
from common_cents.screens._confirm import ConfirmModal
|
||||
from common_cents.widgets.autocomplete import AutocompleteInput
|
||||
from common_cents.widgets.footer import CenteredFooter
|
||||
|
||||
# Sentinel returned when user wants to clear an optional text field. Stored in the
|
||||
# diff dict only when the user explicitly typed [clear] in the Merchant or Notes
|
||||
# field while its checkbox is on. Bulk-applying this writes None to the column.
|
||||
_CLEAR_TOKEN = "[clear]"
|
||||
|
||||
|
||||
def _split_tags(value: str) -> list[str]:
|
||||
return [t.strip() for t in value.split(",") if t.strip()]
|
||||
|
||||
|
||||
class BulkEditScreen(AppScreen[int]):
|
||||
"""Edit a set of spending rows in one shot. Returns the count actually
|
||||
updated (0 means cancelled / nothing applicable). Only fields whose
|
||||
checkbox is on are written to each row; everything else is preserved
|
||||
via a per-row read of get_spending_by_id."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
BulkEditScreen VerticalScroll {
|
||||
padding: 1 2;
|
||||
}
|
||||
BulkEditScreen #subtitle {
|
||||
height: auto;
|
||||
padding: 0 0 1 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
BulkEditScreen .row {
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
BulkEditScreen .check {
|
||||
width: 5;
|
||||
height: auto;
|
||||
padding-top: 1;
|
||||
}
|
||||
BulkEditScreen .field {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
BulkEditScreen .field Label {
|
||||
height: 1;
|
||||
}
|
||||
BulkEditScreen Input, BulkEditScreen AutocompleteInput {
|
||||
width: 1fr;
|
||||
}
|
||||
BulkEditScreen #actions {
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
align: right middle;
|
||||
border-top: solid $primary;
|
||||
}
|
||||
BulkEditScreen #actions Button {
|
||||
margin-left: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
||||
|
||||
_FIELDS = ("date", "category", "merchant", "tags", "notes")
|
||||
|
||||
def __init__(self, spending_ids: list[int]) -> None:
|
||||
super().__init__()
|
||||
self._ids = list(spending_ids)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with VerticalScroll():
|
||||
yield Static(id="subtitle")
|
||||
yield Label(
|
||||
"Tick a row's checkbox to apply that field. Typing in a field "
|
||||
"auto-ticks its box. Use [clear] in Merchant or Notes to blank "
|
||||
"them. Tags replace the existing tag set on each record.",
|
||||
id="help",
|
||||
)
|
||||
with Horizontal(classes="row"):
|
||||
yield Checkbox(value=False, classes="check", id="cb-date")
|
||||
with Vertical(classes="field"):
|
||||
yield Label("Date")
|
||||
yield Input(placeholder="YYYY-MM-DD", id="field-date")
|
||||
with Horizontal(classes="row"):
|
||||
yield Checkbox(value=False, classes="check", id="cb-category")
|
||||
with Vertical(classes="field"):
|
||||
yield Label("Category")
|
||||
yield AutocompleteInput(
|
||||
placeholder="Category name", id="field-category"
|
||||
)
|
||||
with Horizontal(classes="row"):
|
||||
yield Checkbox(value=False, classes="check", id="cb-merchant")
|
||||
with Vertical(classes="field"):
|
||||
yield Label("Merchant")
|
||||
yield AutocompleteInput(
|
||||
placeholder="Merchant name (or [clear])", id="field-merchant"
|
||||
)
|
||||
with Horizontal(classes="row"):
|
||||
yield Checkbox(value=False, classes="check", id="cb-tags")
|
||||
with Vertical(classes="field"):
|
||||
yield Label("Tags (replace, comma-separated)")
|
||||
yield Input(placeholder="work, lunch", id="field-tags")
|
||||
with Horizontal(classes="row"):
|
||||
yield Checkbox(value=False, classes="check", id="cb-notes")
|
||||
with Vertical(classes="field"):
|
||||
yield Label("Notes")
|
||||
yield Input(placeholder="Notes (or [clear])", id="field-notes")
|
||||
with Horizontal(id="actions"):
|
||||
yield Button("Apply", variant="primary", id="apply")
|
||||
yield Button("Cancel", id="cancel")
|
||||
yield CenteredFooter()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = "Bulk Edit Spending"
|
||||
n = len(self._ids)
|
||||
self.query_one("#subtitle", Static).update(
|
||||
f"Editing {n} selected record{'s' if n != 1 else ''}."
|
||||
)
|
||||
categories = self.db.get_metadata("category")
|
||||
merchants = self.db.get_metadata("merchant")
|
||||
self.query_one("#field-category", AutocompleteInput).set_options(
|
||||
[c["name"] for c in categories]
|
||||
)
|
||||
self.query_one("#field-merchant", AutocompleteInput).set_options(
|
||||
[m["name"] for m in merchants]
|
||||
)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
# AutocompleteInput emits Input.Changed via its inner Input child; the
|
||||
# event ID matches the inner field-* widget. Auto-tick the checkbox so
|
||||
# the user doesn't have to remember the dual-step.
|
||||
input_id = event.input.id
|
||||
if not input_id or not input_id.startswith("field-"):
|
||||
return
|
||||
field = input_id[len("field-") :]
|
||||
if field not in self._FIELDS:
|
||||
return
|
||||
if event.value.strip():
|
||||
self.query_one(f"#cb-{field}", Checkbox).value = True
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "apply":
|
||||
self._apply()
|
||||
elif event.button.id == "cancel":
|
||||
self.dismiss(0)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(0)
|
||||
|
||||
def _collect_diff(self) -> dict | None:
|
||||
"""Read the form into a dict of overrides (only checked fields). Returns
|
||||
None on validation error (after notifying the user); empty dict if no
|
||||
boxes were checked."""
|
||||
diff: dict = {}
|
||||
if self.query_one("#cb-date", Checkbox).value:
|
||||
v = self.query_one("#field-date", Input).value.strip()
|
||||
try:
|
||||
date.fromisoformat(v)
|
||||
except ValueError:
|
||||
self.app.notify("Invalid date — use YYYY-MM-DD", severity="error")
|
||||
return None
|
||||
diff["date"] = v
|
||||
if self.query_one("#cb-category", Checkbox).value:
|
||||
v = self.query_one("#field-category", AutocompleteInput).value.strip()
|
||||
if not v:
|
||||
self.app.notify("Category cannot be blank", severity="error")
|
||||
return None
|
||||
diff["category"] = v
|
||||
if self.query_one("#cb-merchant", Checkbox).value:
|
||||
v = self.query_one("#field-merchant", AutocompleteInput).value.strip()
|
||||
diff["merchant"] = None if v.lower() == _CLEAR_TOKEN else (v or None)
|
||||
if self.query_one("#cb-tags", Checkbox).value:
|
||||
v = self.query_one("#field-tags", Input).value
|
||||
diff["tag_names"] = _split_tags(v)
|
||||
if self.query_one("#cb-notes", Checkbox).value:
|
||||
v = self.query_one("#field-notes", Input).value.strip()
|
||||
diff["notes"] = None if v.lower() == _CLEAR_TOKEN else (v or None)
|
||||
return diff
|
||||
|
||||
def _apply(self) -> None:
|
||||
diff = self._collect_diff()
|
||||
if diff is None:
|
||||
return
|
||||
if not diff:
|
||||
self.app.notify("No fields ticked — nothing to apply.", severity="warning")
|
||||
return
|
||||
|
||||
n = len(self._ids)
|
||||
details = [f"Records: {n}"]
|
||||
for k, v in diff.items():
|
||||
if k == "tag_names":
|
||||
details.append(
|
||||
"Tags: " + (", ".join(v) if v else "[clear]")
|
||||
)
|
||||
else:
|
||||
details.append(f"{k.capitalize():9} {v if v is not None else '[clear]'}")
|
||||
|
||||
def on_confirm(confirmed: bool) -> None:
|
||||
if not confirmed:
|
||||
return
|
||||
try:
|
||||
updated = self._write(diff)
|
||||
except Exception as e:
|
||||
self.app.notify(f"Bulk update failed: {e}", severity="error")
|
||||
return
|
||||
self.app.notify(f"Updated {updated} record(s).")
|
||||
self.dismiss(updated)
|
||||
|
||||
self.app.push_screen(
|
||||
ConfirmModal(
|
||||
f"Apply changes to {n} record{'s' if n != 1 else ''}?",
|
||||
details=details,
|
||||
confirm_label="Apply",
|
||||
confirm_variant="warning",
|
||||
cancel_label="Cancel",
|
||||
focus_confirm=True,
|
||||
),
|
||||
on_confirm,
|
||||
)
|
||||
|
||||
def _write(self, diff: dict) -> int:
|
||||
"""Apply `diff` to every selected ID. Each row is fetched fresh, the
|
||||
diff is overlaid on its current values, and update_spending writes the
|
||||
result. Skips IDs that no longer exist (e.g. deleted in another window)."""
|
||||
tag_ids: list[int] | None = None
|
||||
if "tag_names" in diff:
|
||||
tag_ids = [
|
||||
self.db.get_or_create_metadata("tag", name)
|
||||
for name in diff["tag_names"]
|
||||
]
|
||||
|
||||
updated = 0
|
||||
for spending_id in self._ids:
|
||||
row = self.db.get_spending_by_id(spending_id)
|
||||
if row is None:
|
||||
continue
|
||||
new_date = diff.get("date", row["date"])
|
||||
new_category = diff.get("category", row["category_name"])
|
||||
new_merchant = (
|
||||
diff["merchant"] if "merchant" in diff else row["merchant_name"]
|
||||
)
|
||||
new_notes = diff["notes"] if "notes" in diff else row["notes"]
|
||||
new_tag_ids = (
|
||||
tag_ids
|
||||
if tag_ids is not None
|
||||
else self.db.get_spending_metadata_ids(spending_id, "tag")
|
||||
)
|
||||
self.db.update_spending(
|
||||
spending_id,
|
||||
new_date,
|
||||
row["cents"],
|
||||
new_category,
|
||||
new_merchant,
|
||||
new_notes,
|
||||
new_tag_ids,
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
Loading…
Add table
Add a link
Reference in a new issue