Umsatz-modul eingebaut, buchen noch nicht getestet

This commit is contained in:
2026-03-24 17:28:21 +01:00
parent 126c2fc7d7
commit 2d2eb2fa1c
10 changed files with 428 additions and 70 deletions
+115
View File
@@ -0,0 +1,115 @@
# module_posting.R
postingModuleUI <- function(id) {
ns <- NS(id)
tagList() # Das UI wird dynamisch via showModal erzeugt
}
postingModuleServer <- function(id, conn, r_trans_id, r_trigger_list) {
moduleServer(id, function(input, output, session) {
ns <- session$ns
# Dieser Trigger signalisiert dem Hauptmodul: "Daten haben sich geändert!"
db_updated <- reactiveVal(0)
# Hilfsvariablen für den aktuellen Edit-Status
current_record <- reactiveVal()
current_trans <- reactiveVal()
# Beobachte den Trigger aus dem Hauptmodul
observeEvent(r_trigger_list(), {
req(r_trans_id())
# 1. Daten laden
t_id <- r_trans_id()
p_id <- r_trigger_list()$post_id
trans_data <- dbxSelect(conn, paste0("SELECT * FROM entries WHERE id=", t_id))
current_trans(trans_data)
if (!is.null(p_id)) {
record <- read_posting(conn, p_id)
} else {
# Logik für neuen Eintrag
new_p_id <- max_id(conn, "postings") + 1
wertstellung <- dbxSelect(conn, paste0("SELECT max(valuta) from Postings WHERE entry_id=", t_id)) %>% pull()
record <- leer_df_from_table(conn, "postings")
record[1, "id"] <- new_p_id
record$entry_id <- t_id
record$account_id <- 0
record$valuta <- wertstellung
record$booking_date <- wertstellung
}
current_record(record)
# 2. Modal anzeigen
showModal(modalDialog(
title = paste("Buchung bearbeiten - Transaktion", t_id),
f_airdatepicker_UI(ns("valuta"), "Wertstellung", record$valuta),
f_airdatepicker_UI(ns("buchungsdatum"), "Buchungsdatum", record$booking_date),
selectizeInput(ns("kontakt"), "Kontakt:", selected = trans_data$contact_id, choices = get_contact_choices(conn), width = "100%"),
selectizeInput(ns("konto"), "Kontoname:", selected = record$account_id, choices = get_account_choices(conn), width = "100%"),
selectizeInput(ns("projekt"), "Projektname", selected = record$project_id, choices = get_project_choices(conn), width = "100%"),
splitLayout(cellWidths = c("70%", "30%"),
numericInput(ns("amount"), "Betrag:", value = record$amount, width = "100%"),
numericInput(ns("coin_share_amount"), "Münzen:", value = record$coin_share_amount, width = "100%")
),
textAreaInput(ns("trans_notiz"), label = "Notiz (Transaktion)", value = trans_data$note, width = "100%"),
footer = tagList(
modalButton("Abbrechen"),
actionBttn(ns("delete"), "Löschen", style = "minimal", color = "danger"),
actionBttn(ns("confirm"), "Bestätigen", style = "minimal", color = "success")
),
easyClose = TRUE
))
})
# Speichern
observeEvent(input$confirm, {
req(current_record(), current_trans())
# Daten aus UI einsammeln
rec <- current_record()
tra <- current_trans()
tra$contact_id <- input$kontakt
tra$note <- input$trans_notiz
rec$amount <- input$amount
rec$account_id <- input$konto
rec$project_id <- input$projekt
rec$valuta <- as.character(input$valuta)
rec$booking_date <- as.character(input$buchungsdatum)
rec$coin_share_amount <- input$coin_share_amount
# Validierung
ok <- !is.na(rec$amount) && rec$account_id > 0
if (ok) {
dbxUpsert(conn, "postings", records = rec, where_cols = "id")
dbxUpsert(conn, "entries", records = tra, where_cols = "id")
removeModal()
db_updated(db_updated() + 1) # Hauptmodul triggern
} else {
showNotification("Eingabe ungültig", type = "error")
}
})
# Löschen
observeEvent(input$delete, {
rec <- current_record()
anz <- dbxSelect(conn, paste0("SELECT count(*) FROM postings WHERE entry_id=", rec$entry_id)) %>% pull()
if (anz > 1) {
dbxDelete(conn, "postings", where = data.frame(id = rec$id))
removeModal()
db_updated(db_updated() + 1)
} else {
showNotification("Letzter Teil kann nicht gelöscht werden.", type = "error")
}
})
return(db_updated)
})
}
+378
View File
@@ -0,0 +1,378 @@
library(shiny)
library(shinyTree)
# ---- UI ----
ui <- fluidPage(
tags$head(
tags$style(HTML("
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@700&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0f0f0f;
color: #e8e4dc;
font-family: 'DM Mono', monospace;
min-height: 100vh;
padding: 40px 20px;
}
.app-title {
font-family: 'Playfair Display', serif;
font-size: 2.8rem;
color: #f0e6c8;
letter-spacing: -1px;
margin-bottom: 6px;
}
.app-subtitle {
font-size: 0.75rem;
color: #5a5550;
letter-spacing: 4px;
text-transform: uppercase;
margin-bottom: 40px;
}
.main-container {
max-width: 900px;
margin: 0 auto;
}
.panel {
background: #1a1a18;
border: 1px solid #2e2b26;
border-radius: 4px;
padding: 28px;
margin-bottom: 20px;
}
.panel-title {
font-size: 0.65rem;
letter-spacing: 5px;
text-transform: uppercase;
color: #8a7e6e;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #2e2b26;
}
.form-row {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 0.65rem;
letter-spacing: 3px;
text-transform: uppercase;
color: #6b6158;
}
input[type='text'], select {
background: #0f0f0f;
border: 1px solid #2e2b26;
color: #e8e4dc;
font-family: 'DM Mono', monospace;
font-size: 0.85rem;
padding: 10px 14px;
border-radius: 3px;
outline: none;
transition: border-color 0.2s;
min-width: 200px;
}
input[type='text']:focus, select:focus {
border-color: #c8a96e;
}
select option {
background: #1a1a18;
}
.btn-add {
background: #c8a96e;
color: #0f0f0f;
border: none;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 3px;
text-transform: uppercase;
padding: 11px 22px;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.btn-add:hover { background: #dbb97e; }
.btn-add:active { transform: scale(0.97); }
.btn-del {
background: transparent;
color: #8a4040;
border: 1px solid #3d2020;
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
letter-spacing: 2px;
text-transform: uppercase;
padding: 10px 18px;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.btn-del:hover {
background: #2a1515;
border-color: #8a4040;
color: #c46060;
}
/* shinyTree overrides */
#todo_tree {
margin-top: 10px;
}
.jstree-default .jstree-anchor {
font-family: 'DM Mono', monospace !important;
font-size: 0.88rem !important;
color: #c8c0b0 !important;
padding: 3px 6px !important;
}
.jstree-default .jstree-hovered {
background: #252520 !important;
border-radius: 3px !important;
box-shadow: none !important;
}
.jstree-default .jstree-clicked {
background: #2a2820 !important;
border-left: 2px solid #c8a96e !important;
border-radius: 3px !important;
box-shadow: none !important;
color: #f0e6c8 !important;
}
.jstree-default .jstree-icon {
color: #5a5048 !important;
}
.jstree-default .jstree-node {
margin: 2px 0 !important;
}
/* category badges */
.badge-arbeit { color: #7ab8d4; }
.badge-privat { color: #9dbb7a; }
.badge-einkauf { color: #d4a87a; }
.badge-projekt { color: #c47ab8; }
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
font-size: 0.7rem;
color: #5a5550;
letter-spacing: 2px;
text-transform: uppercase;
}
#selected_info {
font-size: 0.78rem;
color: #8a7e6e;
padding: 8px 0;
min-height: 28px;
}
"))
),
div(class = "main-container",
tags$h1(class = "app-title", "TODO"),
tags$p(class = "app-subtitle", "hierarchische aufgabenliste"),
# ADD FORM
div(class = "panel",
div(class = "panel-title", "Neue Aufgabe"),
div(class = "form-row",
div(class = "form-group",
tags$label(`for` = "task_name", "Aufgabe"),
textInput("task_name", label = NULL, placeholder = "Aufgabe eingeben…", width = "280px")
),
div(class = "form-group",
tags$label(`for` = "task_cat", "Kategorie"),
selectInput("task_cat", label = NULL,
choices = c("Arbeit", "Privat", "Einkauf", "Projekt"),
width = "160px")
),
div(class = "form-group",
tags$label(`for` = "parent_choice", "Unter-Aufgabe von"),
selectInput("parent_choice", label = NULL,
choices = c("— (Hauptaufgabe)" = ""),
width = "220px")
),
div(style = "margin-bottom: 1px;",
actionButton("add_task", "Hinzufügen", class = "btn-add")
)
)
),
# TREE
div(class = "panel",
div(class = "panel-title", "Aufgaben"),
div(id = "selected_info", "Klicke auf eine Aufgabe zum Auswählen"),
shinyTree("todo_tree",
checkbox = TRUE,
theme = "default",
themeIcons = TRUE,
themeDots = TRUE,
dragAndDrop = TRUE
),
tags$br(),
div(class = "status-bar",
div(id = "task_count", ""),
actionButton("delete_task", "Auswahl löschen", class = "btn-del")
)
)
)
)
# ---- SERVER ----
server <- function(input, output, session) {
# Reactive store: list of tasks
# Each task: list(id, label, category, parent_id)
tasks <- reactiveVal(list(
list(id = "t1", label = "Projektplanung", category = "Projekt", parent_id = ""),
list(id = "t2", label = "Meeting vorbereiten", category = "Arbeit", parent_id = "t1"),
list(id = "t3", label = "Folien erstellen", category = "Arbeit", parent_id = "t1"),
list(id = "t4", label = "Einkaufsliste", category = "Einkauf", parent_id = ""),
list(id = "t5", label = "Milch & Eier", category = "Einkauf", parent_id = "t4"),
list(id = "t6", label = "Sport", category = "Privat", parent_id = "")
))
id_counter <- reactiveVal(7)
# Helper: build nested list for shinyTree from flat task list
build_tree <- function(tlist, parent_id = "") {
children <- Filter(function(t) t$parent_id == parent_id, tlist)
if (length(children) == 0) return(NULL)
result <- lapply(children, function(t) {
subtree <- build_tree(tlist, t$id)
icon_class <- switch(tolower(t$category),
"arbeit" = "glyphicon glyphicon-briefcase",
"privat" = "glyphicon glyphicon-heart",
"einkauf" = "glyphicon glyphicon-shopping-cart",
"projekt" = "glyphicon glyphicon-star",
"glyphicon glyphicon-ok"
)
node <- list(
text = paste0(t$label, " [", t$category, "]"),
icon = icon_class,
`data-id` = t$id
)
attr(node, "stopSign") <- FALSE
if (!is.null(subtree)) node$children <- subtree
node
})
names(result) <- sapply(children, function(t) t$label)
result
}
# Render tree
output$todo_tree <- renderTree({
build_tree(tasks())
})
# Update parent selector
observe({
tlist <- tasks()
choices <- c("— (Hauptaufgabe)" = "")
for (t in tlist) choices[t$label] <- t$id
updateSelectInput(session, "parent_choice", choices = choices)
})
# Task count
observe({
n <- length(tasks())
updateTextInput(session, "task_count_dummy") # just trigger
})
output$task_count <- renderText(NULL)
observe({
n <- length(tasks())
session$sendCustomMessage("updateCount",
list(id = "task_count", text = paste(n, "aufgaben gesamt")))
})
tags$script("
Shiny.addCustomMessageHandler('updateCount', function(msg) {
document.getElementById(msg.id).textContent = msg.text;
});
")
# Add task
observeEvent(input$add_task, {
req(input$task_name != "")
new_id <- paste0("t", id_counter())
id_counter(id_counter() + 1)
new_task <- list(
id = new_id,
label = trimws(input$task_name),
category = input$task_cat,
parent_id = input$parent_choice
)
tasks(c(tasks(), list(new_task)))
updateTextInput(session, "task_name", value = "")
})
# Show selected
observeEvent(input$todo_tree, {
sel <- get_selected(input$todo_tree, format = "names")
if (length(sel) > 0) {
session$sendCustomMessage("updateCount",
list(id = "selected_info",
text = paste0("✦ Ausgewählt: ", paste(sel, collapse = ", "))))
} else {
session$sendCustomMessage("updateCount",
list(id = "selected_info", text = "Klicke auf eine Aufgabe zum Auswählen"))
}
})
# Delete selected tasks (by label matching)
observeEvent(input$delete_task, {
sel <- get_selected(input$todo_tree, format = "names")
if (length(sel) == 0) return()
tlist <- tasks()
# Get labels without category suffix
sel_labels <- trimws(gsub("\\s*\\[.*?\\]$", "", sel))
# IDs to delete (including children recursively)
delete_ids <- function(ids_to_delete, all_tasks) {
children <- Filter(function(t) t$parent_id %in% ids_to_delete, all_tasks)
if (length(children) == 0) return(ids_to_delete)
child_ids <- sapply(children, function(t) t$id)
c(ids_to_delete, delete_ids(child_ids, all_tasks))
}
matched_ids <- sapply(
Filter(function(t) t$label %in% sel_labels, tlist),
function(t) t$id
)
all_del <- delete_ids(matched_ids, tlist)
tasks(Filter(function(t) !(t$id %in% all_del), tlist))
})
}
shinyApp(ui, server)