diff --git a/R/_funktionen/tree_helpers.R b/R/_funktionen/tree_helpers.R new file mode 100644 index 0000000..29160cf --- /dev/null +++ b/R/_funktionen/tree_helpers.R @@ -0,0 +1,72 @@ +# R/tree_helpers.R + +# Buchungsanzahl rekursiv für einen Knoten und alle Nachkommen +count_recursive <- function(num, df, counts) { + # Alle Nachkommen: alles im Bereich (num, num+1000) je nach Ebene + divisor <- ifelse(num %% 1000 == 0, 1000, + ifelse(num %% 100 == 0, 100, + ifelse(num %% 10 == 0, 10, 1))) + + descendants <- df$id[ + df$num >= num & + df$num < num + divisor + ] + + sum(counts$n[counts$account_id %in% descendants], na.rm = TRUE) +} + +# Label mit Buchungsanzahl +make_label <- function(num, df, counts) { + name <- df$account_name[df$num == num] + total <- count_recursive(num, df, counts) + ifelse(total > 0, + paste0(name, " (", total, ")"), + name + ) +} + +# Baum rekursiv aufbauen +build_level <- function(df, counts, parent_num, divisor) { + if (divisor < 1) return(list()) + + children <- df[ + df$num > parent_num & + df$num < parent_num + divisor * 10 & + df$num %% divisor == 0 & + df$num %% (divisor * 10) != 0, + ] + + if (nrow(children) == 0) return(list()) + + result <- lapply(seq_len(nrow(children)), function(i) { + child_num <- children$num[i] + subtree <- build_level(df, counts, child_num, divisor / 10) + if (length(subtree) == 0) + structure(list(), sttype = "default") + else + subtree + }) + + setNames(result, sapply(children$num, make_label, df = df, counts = counts)) +} + +# Hauptfunktion: kompletten Baum aus accounts-Tabelle bauen +build_account_tree <- function(conn) { + df <- dbReadTable(conn, "accounts") + df$num <- as.integer(substr(df$account_name, 1, 4)) + + counts <- tbl(conn, "postings") %>% + count(account_id) %>% + collect() + + tops <- df[df$num %% 1000 == 0, ] + + tree <- lapply(seq_len(nrow(tops)), function(i) { + build_level(df, counts, tops$num[i], 100) + }) + + setNames( + tree, + sapply(tops$num, make_label, df = df, counts = counts) + ) +} \ No newline at end of file diff --git a/R/accounts/accounts_tab.R b/R/accounts/accounts_tab.R new file mode 100644 index 0000000..70d14f6 --- /dev/null +++ b/R/accounts/accounts_tab.R @@ -0,0 +1,253 @@ +library(shiny) +library(shinyjs) +library(shinyTree) +library(reactable) +library(dplyr) +library(DBI) + +# ── UI ──────────────────────────────────────────────────────────────────────── +accountsUI <- function(id) { + ns <- NS(id) + tagList( + useShinyjs(), + sidebarLayout( + sidebarPanel( + width = 3, + h4("Konten"), + shinyTree(ns("account_tree"), checkbox = FALSE, search = FALSE) + ), + mainPanel( + width = 9, + # Accounts-Felder + wellPanel( + fluidRow( + column(12, + actionButton(ns("new_account"), "Konto neu", + class = "btn-success", icon = icon("plus")), + hr() + ) + ), + fluidRow( + column(4, + textInput(ns("account_name"), "Kontoname"), + textInput(ns("bank_name"), "Bank") + ), + column(4, + numericInput(ns("hibiscus_id"), "Hibiscus Account ID", value = NA), + numericInput(ns("budget_id"), "Budget ID", value = NA) + ), + column(4, + # ID read-only anzeigen + disabled(numericInput(ns("account_id"), "ID", value = NA)), + br(), + actionButton(ns("save_account"), "Speichern", + class = "btn-primary", icon = icon("save")), + textOutput(ns("save_status")) + ) + ) + ), + # Buchungen + h4("Buchungen"), + reactableOutput(ns("postings_table")) + ) + ) + ) +} + +# ── Server ---- +accountsServer <- function(id, conn, r_global) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + # ── Refresh-Trigger ---- + refresh <- reactiveVal(0) + + # ── Kontendaten ---- + accounts <- reactive({ + refresh() + dbReadTable(conn, "accounts") %>% arrange(account_name) + }) + + # ── Baum aufbauen ---- + tree_data <- reactive({ + refresh() + build_account_tree(conn) + }) + + output$account_tree <- renderTree({ + tree_data() + }) + + # ── Gewähltes Konto ──────────────────────────────────────────────────────── + selected_name <- reactive({ + sel <- get_selected(input$account_tree, format = "names") + cat("raw selected:", paste(sel, collapse=", "), "\n") + if (length(sel) == 0) return(NULL) + name <- sub(" \\(\\d+\\)$", "", sel[[1]]) + cat("cleaned name:", name, "\n") + name + }) + + selected_account <- reactive({ + req(selected_name()) + df <- accounts() + df[df$account_name == selected_name(), ] + }) + + # ── Inputs befüllen ---- + observeEvent(selected_account(), { + acc <- selected_account() + req(nrow(acc) > 0) + updateNumericInput(session, "account_id", value = acc$id) + updateTextInput(session, "account_name", value = acc$account_name) + updateTextInput(session, "bank_name", value = acc$bank_name %||% "") + updateNumericInput(session, "hibiscus_id", value = acc$hibiscus_account_id) + updateNumericInput(session, "budget_id", value = acc$budget_id) + }) + + # ── Neu-Button ---- + observeEvent(input$new_account, { + neue_id <- max_id(conn, "accounts") + 1L + updateNumericInput(session, "account_id", value = neue_id) + updateTextInput(session, "account_name", value = "") + updateTextInput(session, "bank_name", value = "") + updateNumericInput(session, "hibiscus_id", value = NA) + updateNumericInput(session, "budget_id", value = 0) + }) + + # ── Buchungsanzahl für gewähltes Konto ---- + has_postings <- reactive({ + req(input$account_id) + count <- tbl(conn, "postings") %>% + filter(account_id == !!input$account_id) %>% + count() %>% + pull(n) + count > 0 + }) + + # ── Lösch-Button sperren/freigeben ---- + observe({ + req(input$account_id) + if (has_postings()) { + shinyjs::disable("delete_account") + shinyjs::runjs(sprintf( + "$('#%s').attr('title', 'Konto hat Buchungen und kann nicht gelöscht werden')", + ns("delete_account") + )) + } else { + shinyjs::enable("delete_account") + shinyjs::runjs(sprintf( + "$('#%s').removeAttr('title')", + ns("delete_account") + )) + } + }) + + # ── Speichern (upsert ---- + observeEvent(input$save_account, { + record <- data.frame( + id = input$account_id, + account_name = input$account_name, + bank_name = input$bank_name, + hibiscus_account_id = input$hibiscus_id, + budget_id = input$budget_id, + stringsAsFactors = FALSE + ) + + dbxUpsert(conn, "accounts", record, where_cols = c("id")) + refresh(refresh() + 1) + + output$save_status <- renderText("✓ Gespeichert") + shinyjs::delay(2000, output$save_status <- renderText("")) + }) + + # ── Löschen ---- + observeEvent(input$delete_account, { + req(input$account_id) + showModal(modalDialog( + title = "Konto löschen", + paste0("Konto '", input$account_name, "' wirklich löschen?"), + footer = tagList( + modalButton("Abbrechen"), + actionButton(ns("delete_confirm"), "Ja, löschen", class = "btn-danger") + ) + )) + }) + + observeEvent(input$delete_confirm, { + dbxDelete(conn, "accounts", data.frame(id = input$account_id)) + removeModal() + refresh(refresh() + 1) + + output$save_status <- renderText("✓ Gelöscht") + shinyjs::delay(2000, output$save_status <- renderText("")) + + updateNumericInput(session, "account_id", value = NA) + updateTextInput(session, "account_name", value = "") + updateTextInput(session, "bank_name", value = "") + updateNumericInput(session, "hibiscus_id", value = NA) + updateNumericInput(session, "budget_id", value = NA) + }) + + # ── Buchungen laden (read-only ---- + postings_data <- reactive({ + req(selected_account()) + acc <- selected_account() + req(nrow(acc) > 0) + refresh() + read_buch_tabelle(conn) %>% + filter(account_id == acc$id) + }) + + # ── Reactable ---- + output$postings_table <- renderReactable({ + req(postings_data()) + + reactable( + postings_data() %>% + select(valuta, projektname, display_name, amount, n_attachments), + striped = TRUE, + highlight = TRUE, + selection = "single", + onClick = "select", + defaultSorted = list(valuta = "desc"), + columns = list( + valuta = colDef(name = "Datum", maxWidth = 100), + projektname = colDef(name = "Projekt", maxWidth = 120), + display_name = colDef(name = "Kontakt"), + amount = colDef( + name = "Betrag", + maxWidth = 110, + align = "right", + format = colFormat(currency = "EUR", separators = TRUE, locales = "de-DE") + ), + n_attachments = colDef(name = "Anhänge", maxWidth = 80, align = "center") + ) + ) + }) + + # ── Sprung zu Buchungen via r_global ---- + sel <- reactive(getReactableState("postings_table", "selected")) + + observeEvent(sel(), ignoreInit = TRUE, { + req(sel(), postings_data()) + entry_id <- postings_data()$entry_id[sel()] + # In accountsServer vor dem Sprung: + r_global$nav_history <- c(r_global$nav_history, list(list( + tab = "konten", + account_name = selected_name() + ))) + r_global$jump_to_entry_id <- entry_id + r_global$active_tab <- "buchungen" + r_global$jump_to_entry_id <- entry_id + r_global$active_tab <- "buchungen" + }) + + # ── Rückgabe ─────────────────────────────────────────────────────────────── + return(list( + selected_account = selected_account + )) + }) +} + +# ── Hilfsfunktion ────────────────────────────────────────────────────────────── +`%||%` <- function(a, b) if (is.na(a) || is.null(a) || a == "") b else a \ No newline at end of file diff --git a/R/buchungen_mod.R b/R/buchungen_mod.R index 1ed0b9d..f26e8f1 100644 --- a/R/buchungen_mod.R +++ b/R/buchungen_mod.R @@ -24,32 +24,38 @@ buchungenUI <- function(id) { ) } -buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { +buchungenServer <- function(id, conn, r_global) { moduleServer(id, function(input, output, session) { ns <- session$ns - # * Reactive Variablen ---- + # ── Reactive Variablen ───────────────────────────────────────────────────── postings_data <- reactiveVal(read_buch_tabelle(conn)) details_data <- reactiveVal(NULL) selected_trans_id <- reactiveVal(NULL) current_main_idx <- reactiveVal(NULL) - reset_trigger <- reactiveVal(0) # ← neu: erzwingt Re-render mit Filter-Reset + reset_trigger <- reactiveVal(0) modal_trigger <- reactiveVal(list(post_id = NULL, counter = 0)) highlighted_valuta <- reactiveVal(NULL) + update_db_trigger <- postingModuleServer( + "posting_modal", conn, selected_trans_id, modal_trigger + ) - update_db_trigger <- postingModuleServer("posting_modal", conn, selected_trans_id, modal_trigger) + # ── Filter ───────────────────────────────────────────────────────────────── + aktiver_filter <- reactive(r_global$buchungen_filter) gefilterte_daten <- reactive({ d <- postings_data() - switch(aktiver_filter(), - "giro" = d |> filter(grepl("0130", account_name)), - "monat" = d |> filter( - floor_date(as.Date(valuta), "month") == floor_date(Sys.Date(), "month") - - ), - d + f <- aktiver_filter() + if (f == "alle") d + else if (f == "giro") d |> filter(grepl("0130", account_name)) + else if (f == "monat") d |> filter( + floor_date(as.Date(valuta), "month") == floor_date(Sys.Date(), "month") ) + else if (startsWith(f, "contact:")) d |> filter(contact_id == as.integer(sub("contact:", "", f))) + else if (startsWith(f, "project:")) d |> filter(project_id == as.integer(sub("project:", "", f))) + else if (startsWith(f, "account:")) d |> filter(account_id == as.integer(sub("account:", "", f))) + else d }) observeEvent(aktiver_filter(), { @@ -58,29 +64,55 @@ buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { details_data(NULL) }) - # * Haupttabelle rendern ---- + # ── Sprung von anderem Modul ─────────────────────────────────────────────── + observeEvent(r_global$jump_to_entry_id, ignoreInit = TRUE, { + req(r_global$jump_to_entry_id) + t_id <- r_global$jump_to_entry_id + + # Daten neu laden + postings_data(read_buch_tabelle(conn)) + + # Zeile suchen + idx <- which(gefilterte_daten()$entry_id == t_id)[1] + req(!is.na(idx)) + + current_main_idx(idx) + selected_trans_id(t_id) + details_data(read_buch_tabelle(conn, trans_id = t_id)) + highlighted_valuta(gefilterte_daten()[idx, "valuta"]) + + reset_trigger(isolate(reset_trigger()) + 1) + scroll_to_row(ns("buchungen_table"), idx) + + # Sprungziel zurücksetzen + r_global$jump_to_entry_id <- NULL + }) + + # ── Haupttabelle rendern ─────────────────────────────────────────────────── output$buchungen_table <- renderReactable({ reset_trigger() f_reactable( - daten = gefilterte_daten(), # ← geändert - coldefs = coldef_entries_tabelle, - selection = "single", - hoehe = "60vh", - defaultSelected = current_main_idx(), + daten = gefilterte_daten(), + coldefs = coldef_entries_tabelle, + selection = "single", + hoehe = "60vh", + defaultSelected = current_main_idx(), highlight_valuta = highlighted_valuta() ) }) - # * Details-Tabelle laden ---- + # ── Details laden ────────────────────────────────────────────────────────── sel_details <- reactive(getReactableState("buchungen_table", "selected")) + observeEvent(sel_details(), ignoreInit = TRUE, { req(sel_details()) highlighted_valuta(gefilterte_daten()[sel_details(), "valuta"]) current_main_idx(sel_details()) - t_id <- gefilterte_daten()[sel_details(), "entry_id"] |> pull() # ← geändert + t_id <- gefilterte_daten()[sel_details(), "entry_id"] |> pull() selected_trans_id(t_id) details_data(read_buch_tabelle(conn, trans_id = t_id)) }) + output$details_table <- renderReactable({ req(details_data()) f_reactable( @@ -91,13 +123,13 @@ buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { ) }) - # * Detail hinzufügen ---- + # ── Detail hinzufügen ────────────────────────────────────────────────────── observeEvent(input$add_detail, { req(selected_trans_id()) modal_trigger(list(post_id = NULL, counter = modal_trigger()$counter + 1)) }) - # * Detail editieren (Klick auf Detail-Tabelle) ---- + # ── Detail editieren ─────────────────────────────────────────────────────── sel_detail <- reactive(getReactableState("details_table", "selected")) observeEvent(sel_detail(), ignoreInit = TRUE, { @@ -105,41 +137,42 @@ buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { modal_trigger(list(post_id = p_id, counter = modal_trigger()$counter + 1)) }) - # * DB-Update durch Modal ---- + # ── DB-Update durch Modal ────────────────────────────────────────────────── observeEvent(update_db_trigger(), ignoreInit = TRUE, { req(selected_trans_id()) details_data(read_buch_tabelle(conn, trans_id = selected_trans_id())) postings_data(read_buch_tabelle(conn)) - # Kein Re-render nötig – Filter/Sortierung bleibt erhalten - updateReactable("buchungen_table", data = postings_data()) + updateReactable("buchungen_table", data = gefilterte_daten()) }) - # * Neue Transaktion ---- + # ── Neue Transaktion ─────────────────────────────────────────────────────── observeEvent(input$add_trans, { new_t_id <- max_id(conn, "entries") + 1 dbxInsert(conn, "entries", data.frame(id = new_t_id)) p_id1 <- max_id(conn, "postings") + 1 p_id2 <- p_id1 + 1 dbxInsert(conn, "postings", data.frame( - id = c(p_id1, p_id2), - entry_id = c(new_t_id, new_t_id), - amount = c(0, 0), + id = c(p_id1, p_id2), + entry_id = c(new_t_id, new_t_id), + amount = c(0, 0), account_id = c(0, 0), - valuta = c(Sys.Date(), Sys.Date()) + valuta = c(Sys.Date(), Sys.Date()) )) - ## ** Daten aktualisieren ---- + + # Filter zurücksetzen damit neue Transaktion sichtbar ist + r_global$buchungen_filter <- "alle" + postings_data(read_buch_tabelle(conn)) selected_trans_id(new_t_id) details_data(read_buch_tabelle(conn, trans_id = new_t_id)) - ## ** Index in neuen Daten suchen ---- + neue_zeile <- which(postings_data()$id == p_id1) current_main_idx(neue_zeile) - ## ** Re-render erzwingen → Filter wird zurückgesetzt, neue Zeile selektiert ---- reset_trigger(isolate(reset_trigger()) + 1) scroll_to_row(ns("buchungen_table"), neue_zeile) }) - # * Transaktion löschen ---- + # ── Transaktion löschen ──────────────────────────────────────────────────── observeEvent(input$del_trans, ignoreInit = TRUE, { req(selected_trans_id()) dbxDelete(conn, "postings", where = data.frame(entry_id = selected_trans_id())) @@ -148,11 +181,10 @@ buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { current_main_idx(NULL) selected_trans_id(NULL) details_data(NULL) - updateReactable("buchungen_table", data = postings_data()) - # details_table re-rendert automatisch weil details_data(NULL) → req() schlägt fehl + updateReactable("buchungen_table", data = gefilterte_daten()) }) - + # ── Anhänge ──────────────────────────────────────────────────────────────── output$attachments_ui <- renderUI({ req(selected_trans_id()) att <- dbxSelect(conn, paste0( @@ -164,10 +196,10 @@ buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { ext <- tools::file_ext(att$original_name[i]) filename <- paste0("attachments/", att$id[i], ".", ext) - # Observer direkt hier registrieren observeEvent(input[[paste0("open_att_", att$id[i])]], { showModal(modalDialog( - tags$iframe(src = filename, width = "100%", height = "600px", style = "border:none;"), + tags$iframe(src = filename, width = "100%", height = "600px", + style = "border:none;"), title = att$original_name[i], size = "l", easyClose = TRUE, @@ -177,7 +209,7 @@ buchungenServer <- function(id, conn, aktiver_filter = reactive("alle")) { div( actionLink(ns(paste0("open_att_", att$id[i])), att$original_name[i]), - actionLink(ns(paste0("del_att_", att$id[i])), "✕", + actionLink(ns(paste0("del_att_", att$id[i])), "✕", style = "color:red; margin-left:8px;") ) })) diff --git a/R/postings/buchungen.R b/R/postings/buchungen.R index b1d0696..003f20a 100644 --- a/R/postings/buchungen.R +++ b/R/postings/buchungen.R @@ -22,7 +22,7 @@ read_buch_tabelle <- function(conn, trans_id = NULL){ left_join(accounts, by = c("account_id" = "id")) |> left_join(projects, by = c("project_id" = "id")) |> left_join(attachment_counts, by = "entry_id") |> - select(id, valuta, account_name, projektname, display_name, amount, entry_id, n_attachments) |> + select(id, valuta, account_name, projektname, display_name, amount, entry_id, n_attachments, account_id) |> collect() %>% mutate( saldo = 0, @@ -43,6 +43,8 @@ coldef_entries_tabelle <- account_name = colDef(name = "Kontoname", width = 200), projektname = colDef(name = "Projektname", width = 150), display_name = colDef(name = "Kontakt", width = 250), + account_id = colDef(show = FALSE), + entry_id = colDef(show = FALSE), amount = colDef(name = "Betrag", width = 120, format = colFormat(prefix = "", separators = TRUE, digits = 2), style = function(value) { diff --git a/db/development.sqlite b/db/development.sqlite index e5debca..643836f 100644 Binary files a/db/development.sqlite and b/db/development.sqlite differ diff --git a/server.R b/server.R index c777fa0..a34d638 100644 --- a/server.R +++ b/server.R @@ -1,14 +1,49 @@ -server <- function(input, output) { - aktiver_filter <- reactive({ - if (input$filter_giro > 0 && - input$filter_giro == max(input$filter_giro, input$filter_monat, input$filter_alle)) - "giro" - else if (input$filter_monat > 0 && - input$filter_monat == max(input$filter_giro, input$filter_monat, input$filter_alle)) - "monat" - else - "alle" - }) - buchungenServer("buchungen_tab", conn, aktiver_filter = aktiver_filter) +server <- function(input, output, session) { -} + # ── Globaler Navigations-Bus ─────────────────────────────────────────────── + r_global <- reactiveValues( + # Navigation + active_tab = NULL, + nav_history = list(), # ← Stack der letzten Positionen + + # Filter + Sprungziele + buchungen_filter = "alle", + jump_to_entry_id = NULL, + jump_to_account_id = NULL, + jump_to_contact_id = NULL, + jump_to_project_id = NULL + ) + + # ── Tab-Wechsel ──────────────────────────────────────────────────────────── + observeEvent(r_global$active_tab, ignoreInit = TRUE, { + req(r_global$active_tab) + cat("Tab-Wechsel zu:", r_global$active_tab, "\n") + updateTabItems(session, "tabs", selected = r_global$active_tab) + r_global$active_tab <- NULL + }) + + observeEvent(input$back_btn, { + req(length(r_global$nav_history) > 0) + + # Letzten Zustand holen und Stack kürzen + last <- tail(r_global$nav_history, 1)[[1]] + r_global$nav_history <- head(r_global$nav_history, -1) + + # Zurückspringen + updateTabItems(session, "tabs", selected = last$tab) + + # Zustand wiederherstellen — je nach Tab + if (last$tab == "konten") { + r_global$jump_to_account_name <- last$account_name + } + }) + + # ── Filter-Buttons (falls in der Navbar oder UI definiert) ───────────────── + observeEvent(input$filter_alle, { r_global$buchungen_filter <- "alle" }) + observeEvent(input$filter_giro, { r_global$buchungen_filter <- "giro" }) + observeEvent(input$filter_monat, { r_global$buchungen_filter <- "monat" }) + + # ── Module ───────────────────────────────────────────────────────────────── + accountsServer("accounts_tab", conn, r_global) + buchungenServer("buchungen_tab", conn, r_global) +} \ No newline at end of file diff --git a/ui.R b/ui.R index 33d90a1..902b48f 100644 --- a/ui.R +++ b/ui.R @@ -1,6 +1,16 @@ ## ui.R ## dashboardPage( - dashboardHeader( ), + dashboardHeader( + title = "GemFin", + tags$li( + class = "dropdown", + style = "padding: 8px 10px;", + actionBttn("back_btn", "Zurück", + size = "xs", + style = "minimal", + icon = icon("arrow-left")) + ) + ), ## Sidebar content dashboardSidebar( sidebarMenu(id = "tabs", @@ -38,8 +48,8 @@ dashboardPage( buchungenUI("buchungen_tab") ), # Second tab content - tabItem(tabName = "widgets", - h2("Widgets tab content") + tabItem(tabName = "konten", + accountsUI("accounts_tab") ) ) )