diff options
| author | Calvin Morrison <calvin@pobox.com> | 2025-12-31 13:46:44 -0500 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2025-12-31 13:46:44 -0500 |
| commit | a6e05ddd0add4500877ceb2df69ea3e0d5ca9b15 (patch) | |
| tree | 0c1f788b943cb26b9ec13bf77312061fa27cf72e /cashflow.cpp | |
| parent | 5cf763ea3ba2a89acfa5f24422cc71e0ff7fb35b (diff) | |
Fix estimated transaction edit persistence - keep recurring link and set reconciled flag
Diffstat (limited to 'cashflow.cpp')
| -rw-r--r-- | cashflow.cpp | 650 |
1 files changed, 601 insertions, 49 deletions
diff --git a/cashflow.cpp b/cashflow.cpp index e157a8f..6c5ea7d 100644 --- a/cashflow.cpp +++ b/cashflow.cpp @@ -9,6 +9,11 @@ #include <QLocale> #include <QFileDialog> #include <QApplication> +#include <QKeyEvent> +#include <QClipboard> +#include <QFile> +#include <QTextStream> +#include <QFileInfo> CashFlow::CashFlow(QWidget *parent) : QMainWindow(parent) @@ -47,10 +52,92 @@ CashFlow::~CashFlow() delete ui; } +void CashFlow::keyPressEvent(QKeyEvent *event) +{ + // Handle Escape to deselect transaction and clear form + if (event->key() == Qt::Key_Escape) { + QTableWidget *table = ui->transactionTable; + if (table->hasFocus() && !table->selectedItems().isEmpty()) { + table->clearSelection(); + clearTransactionEntry(); + event->accept(); + return; + } + } + + // Handle Ctrl+C to copy selected column + if (event->key() == Qt::Key_C && event->modifiers() == Qt::ControlModifier) { + QTableWidget *table = nullptr; + + // Check which table has focus + if (ui->transactionTable->hasFocus()) { + table = ui->transactionTable; + } else if (ui->recurringTable->hasFocus()) { + table = ui->recurringTable; + } + + if (table) { + QList<QTableWidgetItem*> selected = table->selectedItems(); + + if (selected.isEmpty()) { + QMainWindow::keyPressEvent(event); + return; + } + + // Get all unique columns and rows that have selected items + QSet<int> selectedColumns; + QSet<int> selectedRows; + for (QTableWidgetItem *item : selected) { + selectedColumns.insert(item->column()); + selectedRows.insert(item->row()); + } + + // Copy data from selected columns and rows + // If multiple columns selected, arrange them side-by-side with tabs between columns + QList<int> sortedColumns = selectedColumns.values(); + std::sort(sortedColumns.begin(), sortedColumns.end()); + + QList<int> sortedRows = selectedRows.values(); + std::sort(sortedRows.begin(), sortedRows.end()); + + // Build clipboard text row by row + QString clipboardText; + + // Header row + QStringList headerRow; + for (int col : sortedColumns) { + QString headerText = table->horizontalHeaderItem(col) + ? table->horizontalHeaderItem(col)->text() + : QString("Column %1").arg(col); + headerRow.append(headerText); + } + clipboardText += headerRow.join("\t") + "\n"; + + // Data rows (only selected rows) + for (int row : sortedRows) { + QStringList rowData; + for (int col : sortedColumns) { + QTableWidgetItem *item = table->item(row, col); + rowData.append(item ? item->text() : ""); + } + clipboardText += rowData.join("\t") + "\n"; + } + + QApplication::clipboard()->setText(clipboardText); + event->accept(); + return; + } + } + + QMainWindow::keyPressEvent(event); +} + void CashFlow::setupConnections() { // File menu connect(ui->actionNew, &QAction::triggered, this, &CashFlow::onNewFile); connect(ui->actionOpen, &QAction::triggered, this, &CashFlow::onOpenFile); + connect(ui->actionSaveAs, &QAction::triggered, this, &CashFlow::onSaveAs); + connect(ui->actionExportCSV, &QAction::triggered, this, &CashFlow::onExportCSV); connect(ui->actionQuit, &QAction::triggered, this, &CashFlow::onQuit); // Settings menu @@ -61,7 +148,12 @@ void CashFlow::setupConnections() { connect(ui->dateToEdit, &QDateEdit::dateChanged, this, &CashFlow::onDateRangeChanged); connect(ui->periodCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onPeriodChanged); connect(ui->accountFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onDateRangeChanged); + connect(ui->recurringFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onDateRangeChanged); connect(ui->showAccountBalancesCheck, &QCheckBox::stateChanged, this, &CashFlow::onDateRangeChanged); + connect(ui->searchEdit, &QLineEdit::textChanged, this, &CashFlow::onSearchTextChanged); + connect(ui->collapseAllBtn, &QPushButton::clicked, this, &CashFlow::onCollapseAll); + connect(ui->expandAllBtn, &QPushButton::clicked, this, &CashFlow::onExpandAll); + connect(ui->transactionTable, &QTableWidget::cellDoubleClicked, this, &CashFlow::onTransactionTableDoubleClicked); // Auto-save period and show balances settings connect(ui->periodCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this]() { @@ -121,11 +213,11 @@ void CashFlow::refreshView() { QSet<QString> categories; for (const Transaction &t : database->getAllTransactions()) { if (!t.account.isEmpty()) accounts.insert(t.account); - if (!t.category.isEmpty()) categories.insert(t.category); + if (!t.category.isEmpty() && t.category != "Adjustment") categories.insert(t.category); } for (const RecurringRule &r : database->getAllRecurringRules()) { if (!r.account.isEmpty()) accounts.insert(r.account); - if (!r.category.isEmpty()) categories.insert(r.category); + if (!r.category.isEmpty() && r.category != "Adjustment") categories.insert(r.category); } QStringList sortedAccounts = accounts.values(); @@ -181,8 +273,39 @@ void CashFlow::refreshTransactionTable() { QList<Transaction> allTransactions = getAllTransactionsInRange(); ui->transactionTable->setRowCount(0); - ui->transactionTable->setColumnCount(7); - ui->transactionTable->setHorizontalHeaderLabels({"Date", "Amount", "Balance", "Account", "Category", "Description", "Type"}); + + // Determine if we're showing per-account columns + bool showAccountColumns = ui->showAccountBalancesCheck->isChecked(); + + // Get list of all unique accounts in the date range + QStringList accountList; + if (showAccountColumns) { + QSet<QString> uniqueAccounts; + for (const Transaction &t : allTransactions) { + if (!t.account.isEmpty()) { + uniqueAccounts.insert(t.account); + } + } + accountList = uniqueAccounts.values(); + std::sort(accountList.begin(), accountList.end()); + } + + // Set up columns: Date | Amount | Balance | [Per-account columns] | Account | Category | Description | Type + int baseColumnCount = 3; // Date, Amount, Balance + int accountColumnsCount = accountList.size() * 2; // 2 per account (Amount + Balance) + int totalColumns = baseColumnCount + accountColumnsCount + 5; // + Account, Category, Description, Type, Recurring + ui->transactionTable->setColumnCount(totalColumns); + + QStringList headers = {"Date", "Amount", "Balance"}; + for (const QString &account : accountList) { + headers.append(account + " Amount"); + headers.append(account + " Balance"); + } + headers << "Account" << "Category" << "Description" << "Type" << "Recurring"; + ui->transactionTable->setHorizontalHeaderLabels(headers); + + // Enable column reordering by dragging headers + ui->transactionTable->horizontalHeader()->setSectionsMovable(true); double runningBalance = startingBalance; QMap<QString, double> accountBalances; // Track per-account balances @@ -199,11 +322,19 @@ void CashFlow::refreshTransactionTable() { periodLabel = getPeriodLabel(allTransactions.first().date, periodType, periodCount); } + // Track which period we're in for collapse tracking + int currentPeriodId = 0; + bool inCollapsedPeriod = false; + for (const Transaction &t : allTransactions) { // Check if we've crossed into a new period if (t.date > currentPeriodEnd) { // Insert period end row - insertPeriodEndRow(periodLabel, runningBalance, accountBalances); + currentPeriodId = periodCount; // Use period count as ID + insertPeriodEndRow(periodLabel, runningBalance, accountBalances, currentPeriodId); + + // Check if new period will be collapsed + inCollapsedPeriod = false; // Move to next period periodCount++; @@ -211,6 +342,19 @@ void CashFlow::refreshTransactionTable() { periodLabel = getPeriodLabel(t.date, periodType, periodCount); } + // Check if current period will be collapsed + if (periodCount > 0 && collapsedPeriods[periodType].contains(periodCount)) { + inCollapsedPeriod = true; + } + + // Skip inserting transaction row if we're in a collapsed period + if (inCollapsedPeriod) { + // Still update balances for correct totals + runningBalance += t.amount; + accountBalances[t.account] += t.amount; + continue; + } + // Update balances runningBalance += t.amount; accountBalances[t.account] += t.amount; @@ -256,13 +400,68 @@ void CashFlow::refreshTransactionTable() { } ui->transactionTable->setItem(row, 2, balanceItem); + // Populate per-account amount and balance columns if enabled + if (showAccountColumns) { + for (int i = 0; i < accountList.size(); i++) { + int amountCol = baseColumnCount + (i * 2); // Amount column + int balanceCol = baseColumnCount + (i * 2) + 1; // Balance column + + if (accountList[i] == t.account) { + // This transaction belongs to this account - show amount and balance normally + + // Amount column + QTableWidgetItem *accountAmtItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(t.amount))); + accountAmtItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + accountAmtItem->setFont(currentAmountFont); + accountAmtItem->setFlags(accountAmtItem->flags() & ~Qt::ItemIsEditable); + if (t.amount < 0) { + accountAmtItem->setForeground(QColor(200, 0, 0)); + } else { + accountAmtItem->setForeground(QColor(0, 150, 0)); + } + ui->transactionTable->setItem(row, amountCol, accountAmtItem); + + // Balance column + QTableWidgetItem *accountBalItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(accountBalances[t.account]))); + accountBalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + accountBalItem->setFont(currentAmountFont); + accountBalItem->setFlags(accountBalItem->flags() & ~Qt::ItemIsEditable); + if (accountBalances[t.account] < 0) { + accountBalItem->setForeground(QColor(200, 0, 0)); + } + ui->transactionTable->setItem(row, balanceCol, accountBalItem); + } else { + // Other accounts - show balance in grey (no amount change) + QTableWidgetItem *emptyAmtItem = new QTableWidgetItem(""); + emptyAmtItem->setFlags(emptyAmtItem->flags() & ~Qt::ItemIsEditable); + ui->transactionTable->setItem(row, amountCol, emptyAmtItem); + + // Show the current balance for this account in grey + QTableWidgetItem *greyBalItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(accountBalances[accountList[i]]))); + greyBalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + greyBalItem->setFont(currentAmountFont); + greyBalItem->setFlags(greyBalItem->flags() & ~Qt::ItemIsEditable); + greyBalItem->setForeground(QColor(150, 150, 150)); // Grey color for unchanged accounts + ui->transactionTable->setItem(row, balanceCol, greyBalItem); + } + } + } + + // Account, Category, Description, Type, Recurring come after per-account columns + int accountCol = baseColumnCount + accountColumnsCount; + int categoryCol = accountCol + 1; + int descCol = categoryCol + 1; + int typeCol = descCol + 1; + int recurringCol = typeCol + 1; + QTableWidgetItem *accountItem = new QTableWidgetItem(t.account); + accountItem->setTextAlignment(Qt::AlignCenter | Qt::AlignVCenter); accountItem->setFlags(accountItem->flags() & ~Qt::ItemIsEditable); - ui->transactionTable->setItem(row, 3, accountItem); + ui->transactionTable->setItem(row, accountCol, accountItem); QTableWidgetItem *categoryItem = new QTableWidgetItem(t.category); categoryItem->setFlags(categoryItem->flags() & ~Qt::ItemIsEditable); - ui->transactionTable->setItem(row, 4, categoryItem); + ui->transactionTable->setItem(row, categoryCol, categoryItem); // For reconciliation, show calculated status in description QString displayDescription = t.description; @@ -274,7 +473,7 @@ void CashFlow::refreshTransactionTable() { QTableWidgetItem *descItem = new QTableWidgetItem(displayDescription); descItem->setFlags(descItem->flags() & ~Qt::ItemIsEditable); - ui->transactionTable->setItem(row, 5, descItem); + ui->transactionTable->setItem(row, descCol, descItem); QString typeLabel = "Estimated"; if (t.type == TransactionType::Actual) typeLabel = "Actual"; @@ -282,7 +481,22 @@ void CashFlow::refreshTransactionTable() { QTableWidgetItem *typeItem = new QTableWidgetItem(typeLabel); typeItem->setFlags(typeItem->flags() & ~Qt::ItemIsEditable); - ui->transactionTable->setItem(row, 6, typeItem); + ui->transactionTable->setItem(row, typeCol, typeItem); + + // Recurring rule name (if linked to a recurring rule) + QString recurringName = ""; + if (t.recurringId != -1) { + QList<RecurringRule> rules = database->getAllRecurringRules(); + for (const RecurringRule &rule : rules) { + if (rule.id == t.recurringId) { + recurringName = rule.name; + break; + } + } + } + QTableWidgetItem *recurringItem = new QTableWidgetItem(recurringName); + recurringItem->setFlags(recurringItem->flags() & ~Qt::ItemIsEditable); + ui->transactionTable->setItem(row, recurringCol, recurringItem); // Color code: Actual=green, Estimated=yellow, Reconciliation=red if mismatch, green if balanced QColor rowColor; @@ -293,7 +507,7 @@ void CashFlow::refreshTransactionTable() { rowColor = t.type == TransactionType::Actual ? QColor(200, 255, 200) : QColor(255, 255, 200); } - for (int col = 0; col < 7; col++) { + for (int col = 0; col < totalColumns; col++) { if (ui->transactionTable->item(row, col)) { ui->transactionTable->item(row, col)->setBackground(rowColor); } @@ -302,7 +516,8 @@ void CashFlow::refreshTransactionTable() { // Insert final period end row if (!allTransactions.isEmpty()) { - insertPeriodEndRow(periodLabel, runningBalance, accountBalances); + currentPeriodId = periodCount; + insertPeriodEndRow(periodLabel, runningBalance, accountBalances, currentPeriodId); } ui->transactionTable->resizeColumnsToContents(); @@ -311,11 +526,22 @@ void CashFlow::refreshTransactionTable() { ui->transactionTable->setColumnWidth(0, 100); // Date ui->transactionTable->setColumnWidth(1, 100); // Amount ui->transactionTable->setColumnWidth(2, 100); // Balance - ui->transactionTable->setColumnWidth(3, 120); // Account - ui->transactionTable->setColumnWidth(4, 120); // Category + + // Set widths for per-account amount and balance columns + if (showAccountColumns) { + for (int i = 0; i < accountList.size(); i++) { + ui->transactionTable->setColumnWidth(baseColumnCount + (i * 2), 100); // Account Amount + ui->transactionTable->setColumnWidth(baseColumnCount + (i * 2) + 1, 100); // Account Balance + } + } + + // Account, Category, Description, Type come after per-account columns + int accountCol = baseColumnCount + accountColumnsCount; + ui->transactionTable->setColumnWidth(accountCol, 120); // Account + ui->transactionTable->setColumnWidth(accountCol + 1, 120); // Category + ui->transactionTable->setColumnWidth(accountCol + 2, 250); // Description + ui->transactionTable->setColumnWidth(accountCol + 3, 80); // Type ui->transactionTable->horizontalHeader()->setStretchLastSection(false); - ui->transactionTable->setColumnWidth(5, 250); // Description - ui->transactionTable->setColumnWidth(6, 80); // Type } QDate CashFlow::getPeriodEnd(const QDate &date, PeriodType periodType) { @@ -348,17 +574,17 @@ QString CashFlow::getPeriodLabel(const QDate &date, PeriodType periodType, int c QDate periodStart = getPeriodStart(date, periodType); QDate periodEnd = getPeriodEnd(date, periodType); - QString dateRange = QString("%1 - %2").arg(periodStart.toString("MM/dd/yy")).arg(periodEnd.toString("MM/dd/yy")); + QString dateRange = QString("%1 - %2").arg(periodStart.toString("MM/dd")).arg(periodEnd.toString("MM/dd")); switch (periodType) { case Daily: - return QString("DAY %1 (%2) END").arg(count).arg(date.toString("MM/dd/yy")); + return QString("DAY %1 %2 END").arg(count).arg(date.toString("MM/dd")); case Weekly: - return QString("WEEK %1 END (%2)").arg(count).arg(dateRange); + return QString("WEEK %1 END %2").arg(count).arg(dateRange); case Monthly: - return QString("%1 END (%2)").arg(date.toString("MMMM yyyy").toUpper()).arg(dateRange); + return QString("%1 END %2").arg(date.toString("MMMM yyyy").toUpper()).arg(dateRange); case Quarterly: - return QString("Q%1 %2 END (%3)").arg((date.month() - 1) / 3 + 1).arg(date.year()).arg(dateRange); + return QString("Q%1 %2 END %3").arg((date.month() - 1) / 3 + 1).arg(date.year()).arg(dateRange); } return ""; } @@ -384,34 +610,87 @@ QDate CashFlow::getPeriodStart(const QDate &date, PeriodType periodType) { return date; } -void CashFlow::insertPeriodEndRow(const QString &label, double balance, const QMap<QString, double> &accountBalances) { +void CashFlow::insertPeriodEndRow(const QString &label, double balance, const QMap<QString, double> &accountBalances, int periodId) { int row = ui->transactionTable->rowCount(); ui->transactionTable->insertRow(row); - // Build display text with optional account balances - QString displayText = QString("%1 Balance: $%2").arg(label).arg(formatCurrency(balance)); + bool showAccountColumns = ui->showAccountBalancesCheck->isChecked(); - if (ui->showAccountBalancesCheck->isChecked() && !accountBalances.isEmpty()) { - QStringList accountTexts; - QMapIterator<QString, double> it(accountBalances); - while (it.hasNext()) { - it.next(); - accountTexts.append(QString("%1: $%2").arg(it.key()).arg(formatCurrency(it.value()))); - } - displayText += " " + accountTexts.join(" "); + // Get account list (same as in refreshTransactionTable) + QStringList accountList; + if (showAccountColumns) { + accountList = accountBalances.keys(); + std::sort(accountList.begin(), accountList.end()); } - QTableWidgetItem *spanItem = new QTableWidgetItem(displayText); - spanItem->setFont(QFont("Arial", 11, QFont::Bold)); - spanItem->setBackground(QColor(180, 180, 180)); - spanItem->setForeground(balance < 0 ? QColor(200, 0, 0) : QColor(0, 100, 0)); - spanItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); - spanItem->setFlags(spanItem->flags() & ~Qt::ItemIsSelectable); // Make non-selectable + int baseColumnCount = 3; + int accountColumnsCount = showAccountColumns ? (accountList.size() * 2) : 0; + int totalColumns = ui->transactionTable->columnCount(); - ui->transactionTable->setItem(row, 0, spanItem); + // Date column: show period label - SPAN across Date and Amount columns + PeriodType periodType = static_cast<PeriodType>(ui->periodCombo->currentIndex()); + bool isCollapsed = collapsedPeriods[periodType].contains(periodId); + QString displayLabel = label; + if (isCollapsed) { + displayLabel = "▶ " + label; // Right arrow for collapsed + } else { + displayLabel = "▼ " + label; // Down arrow for expanded + } - // Span across all columns - ui->transactionTable->setSpan(row, 0, 1, 7); + QTableWidgetItem *dateItem = new QTableWidgetItem(displayLabel); + dateItem->setFont(QFont("Arial", 11, QFont::Bold)); + dateItem->setBackground(QColor(180, 180, 180)); + dateItem->setForeground(Qt::black); + dateItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); + dateItem->setFlags(dateItem->flags() & ~Qt::ItemIsSelectable); + dateItem->setData(Qt::UserRole, -1); // Mark as period end row + dateItem->setData(Qt::UserRole + 1, periodId); // Store period ID + ui->transactionTable->setItem(row, 0, dateItem); + + // Span the Date cell across Date and Amount columns (columns 0 and 1) + ui->transactionTable->setSpan(row, 0, 1, 2); + + // Balance column: grand total + QTableWidgetItem *balanceItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(balance))); + balanceItem->setFont(QFont("Arial", 11, QFont::Bold)); + balanceItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + balanceItem->setBackground(QColor(180, 180, 180)); + balanceItem->setForeground(balance < 0 ? QColor(200, 0, 0) : QColor(0, 100, 0)); + balanceItem->setFlags(balanceItem->flags() & ~Qt::ItemIsSelectable); + ui->transactionTable->setItem(row, 2, balanceItem); + + // Per-account columns + if (showAccountColumns) { + for (int i = 0; i < accountList.size(); i++) { + int amountCol = baseColumnCount + (i * 2); + int balanceCol = baseColumnCount + (i * 2) + 1; + + // Amount column: empty + QTableWidgetItem *acctAmtItem = new QTableWidgetItem(""); + acctAmtItem->setBackground(QColor(180, 180, 180)); + acctAmtItem->setFlags(acctAmtItem->flags() & ~Qt::ItemIsSelectable); + ui->transactionTable->setItem(row, amountCol, acctAmtItem); + + // Balance column: account balance + double acctBalance = accountBalances.value(accountList[i], 0.0); + QTableWidgetItem *acctBalItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(acctBalance))); + acctBalItem->setFont(QFont("Arial", 11, QFont::Bold)); + acctBalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + acctBalItem->setBackground(QColor(180, 180, 180)); + acctBalItem->setForeground(acctBalance < 0 ? QColor(200, 0, 0) : QColor(0, 100, 0)); + acctBalItem->setFlags(acctBalItem->flags() & ~Qt::ItemIsSelectable); + ui->transactionTable->setItem(row, balanceCol, acctBalItem); + } + } + + // Remaining columns (Account, Category, Description, Type): empty + int accountCol = baseColumnCount + accountColumnsCount; + for (int col = accountCol; col < totalColumns; col++) { + QTableWidgetItem *emptyItem = new QTableWidgetItem(""); + emptyItem->setBackground(QColor(180, 180, 180)); + emptyItem->setFlags(emptyItem->flags() & ~Qt::ItemIsSelectable); + ui->transactionTable->setItem(row, col, emptyItem); + } // Make the row taller ui->transactionTable->setRowHeight(row, 30); @@ -421,15 +700,17 @@ void CashFlow::refreshRecurringTable() { QList<RecurringRule> rules = database->getAllRecurringRules(); ui->recurringTable->setRowCount(0); - ui->recurringTable->setColumnCount(7); - ui->recurringTable->setHorizontalHeaderLabels({"ID", "Name", "Frequency", "Amount", "Account", "Category", "Start Date"}); + ui->recurringTable->setColumnCount(9); + ui->recurringTable->setHorizontalHeaderLabels({"ID", "Name", "Frequency", "Schedule", "Amount", "Account", "Category", "Start Date", "End Date"}); ui->recurringTable->setEditTriggers(QAbstractItemView::NoEditTriggers); for (const RecurringRule &r : rules) { int row = ui->recurringTable->rowCount(); ui->recurringTable->insertRow(row); - ui->recurringTable->setItem(row, 0, new QTableWidgetItem(QString::number(r.id))); + QTableWidgetItem *idItem = new QTableWidgetItem(QString::number(r.id)); + idItem->setData(Qt::UserRole, r.id); // Store numeric value for sorting + ui->recurringTable->setItem(row, 0, idItem); ui->recurringTable->setItem(row, 1, new QTableWidgetItem(r.name)); QString freqStr; @@ -443,20 +724,46 @@ void CashFlow::refreshRecurringTable() { } ui->recurringTable->setItem(row, 2, new QTableWidgetItem(freqStr)); + // Schedule column - human-readable description + QString scheduleStr; + if (r.frequency == RecurrenceFrequency::Weekly || r.frequency == RecurrenceFrequency::BiWeekly) { + if (r.dayOfWeek >= 1 && r.dayOfWeek <= 7) { + QStringList dayNames = {"Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays", "Sundays"}; + scheduleStr = dayNames[r.dayOfWeek - 1]; + } + } else if (r.frequency == RecurrenceFrequency::Monthly) { + if (r.dayOfMonth >= 1 && r.dayOfMonth <= 31) { + QString suffix; + if (r.dayOfMonth == 1 || r.dayOfMonth == 21 || r.dayOfMonth == 31) suffix = "st"; + else if (r.dayOfMonth == 2 || r.dayOfMonth == 22) suffix = "nd"; + else if (r.dayOfMonth == 3 || r.dayOfMonth == 23) suffix = "rd"; + else suffix = "th"; + scheduleStr = QString("on the %1%2").arg(r.dayOfMonth).arg(suffix); + } + } else if (r.frequency == RecurrenceFrequency::Yearly) { + scheduleStr = r.startDate.toString("MMM d"); + } + ui->recurringTable->setItem(row, 3, new QTableWidgetItem(scheduleStr)); + // Amount with color coding, right-align, monospace QTableWidgetItem *amountItem = new QTableWidgetItem(QString("$%1").arg(formatCurrency(r.amount))); amountItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); amountItem->setFont(currentAmountFont); + amountItem->setData(Qt::UserRole, r.amount); // Store numeric value for sorting if (r.amount < 0) { amountItem->setForeground(QColor(200, 0, 0)); // Red for debits } else { amountItem->setForeground(QColor(0, 150, 0)); // Green for credits } - ui->recurringTable->setItem(row, 3, amountItem); + ui->recurringTable->setItem(row, 4, amountItem); + + ui->recurringTable->setItem(row, 5, new QTableWidgetItem(r.account)); + ui->recurringTable->setItem(row, 6, new QTableWidgetItem(r.category)); + ui->recurringTable->setItem(row, 7, new QTableWidgetItem(r.startDate.toString("yyyy-MM-dd"))); - ui->recurringTable->setItem(row, 4, new QTableWidgetItem(r.account)); - ui->recurringTable->setItem(row, 5, new QTableWidgetItem(r.category)); - ui->recurringTable->setItem(row, 6, new QTableWidgetItem(r.startDate.toString("yyyy-MM-dd"))); + // End Date column + QString endDateStr = r.endDate.isValid() ? r.endDate.toString("yyyy-MM-dd") : ""; + ui->recurringTable->setItem(row, 8, new QTableWidgetItem(endDateStr)); } ui->recurringTable->resizeColumnsToContents(); @@ -487,6 +794,8 @@ QList<Transaction> CashFlow::getAllTransactionsInRange() { QDate startDate = ui->dateFromEdit->date(); QDate endDate = ui->dateToEdit->date(); QString accountFilter = ui->accountFilterCombo->currentText(); + int recurringFilter = ui->recurringFilterCombo->currentData().toInt(); + QString searchText = ui->searchEdit->text().trimmed().toLower(); // Safety check if (accountFilter.isEmpty()) { @@ -507,6 +816,31 @@ QList<Transaction> CashFlow::getAllTransactionsInRange() { allTransactions = filtered; } + // Filter by recurring rule if selected + if (recurringFilter != -1) { + QList<Transaction> filtered; + for (const Transaction &t : allTransactions) { + if (t.recurringId == recurringFilter) { + filtered.append(t); + } + } + allTransactions = filtered; + } + + // Filter by search text if present + if (!searchText.isEmpty()) { + QList<Transaction> filtered; + for (const Transaction &t : allTransactions) { + if (t.account.toLower().contains(searchText) || + t.category.toLower().contains(searchText) || + t.description.toLower().contains(searchText) || + QString::number(t.amount).contains(searchText)) { + filtered.append(t); + } + } + allTransactions = filtered; + } + // Sort by date, then reconciliation always last, then by sort_order, then credits before debits std::sort(allTransactions.begin(), allTransactions.end(), [](const Transaction &a, const Transaction &b) { @@ -539,14 +873,16 @@ void CashFlow::onPeriodChanged() { void CashFlow::onTransactionSelected() { QList<QTableWidgetItem*> selected = ui->transactionTable->selectedItems(); if (selected.isEmpty()) { + // No selection - clear the form to create new transaction + clearTransactionEntry(); return; } int row = selected[0]->row(); int id = ui->transactionTable->item(row, 0)->data(Qt::UserRole).toInt(); - // Load from database - QList<Transaction> allTrans = database->getAllTransactions(); + // Load from the current in-range transaction list (includes projections) + QList<Transaction> allTrans = getAllTransactionsInRange(); for (const Transaction &t : allTrans) { if (t.id == id) { currentTransactionId = id; @@ -628,6 +964,20 @@ void CashFlow::onSaveTransaction() { t.expectedAmount = existing.expectedAmount; t.expectedDate = existing.expectedDate; t.reconciled = existing.reconciled; + + // If user manually edited an Estimated transaction from a recurring rule, mark as reconciled + // This prevents regeneration from overwriting the edited values + if (existing.type == TransactionType::Estimated && + existing.recurringId != -1 && + (t.amount != existing.amount || + t.date != existing.date || + t.account != existing.account || + t.category != existing.category || + t.description != existing.description)) { + // User modified the transaction - keep the link but mark as reconciled + t.reconciled = true; // Mark as reconciled so it won't be regenerated + } + // If user is converting Estimated to Actual, mark as reconciled if (existing.type == TransactionType::Estimated && t.type == TransactionType::Actual) { t.reconciled = true; @@ -686,6 +1036,9 @@ void CashFlow::onDeleteTransaction() { return; } + // Remember current row position before delete + int currentRow = ui->transactionTable->currentRow(); + if (QMessageBox::question(this, "Confirm Delete", QString("Delete transaction ID %1?").arg(currentTransactionId)) == QMessageBox::Yes) { if (database->deleteTransaction(currentTransactionId)) { @@ -693,6 +1046,13 @@ void CashFlow::onDeleteTransaction() { recalculateAllReconciliations(); clearTransactionEntry(); refreshView(); + + // Restore selection to next row (or previous if we deleted the last row) + int rowCount = ui->transactionTable->rowCount(); + if (rowCount > 0) { + int newRow = qMin(currentRow, rowCount - 1); + ui->transactionTable->selectRow(newRow); + } } else { QMessageBox::critical(this, "Error", "Failed to delete: " + database->lastError()); } @@ -778,6 +1138,11 @@ void CashFlow::onSaveRecurring() { r.id = currentRecurringId; r.name = ui->recurringNameEdit->text(); r.startDate = ui->recurringStartDateEdit->date(); + r.endDate = ui->recurringEndDateEdit->date(); + // If end date is the minimum date (special value), treat as null + if (r.endDate == ui->recurringEndDateEdit->minimumDate()) { + r.endDate = QDate(); + } r.amount = ui->recurringAmountSpin->value(); r.account = ui->recurringAccountCombo->currentText(); r.category = ui->recurringCategoryCombo->currentText(); @@ -946,6 +1311,7 @@ void CashFlow::clearRecurringEntry() { currentRecurringId = -1; ui->recurringNameEdit->clear(); ui->recurringStartDateEdit->setDate(QDate::currentDate()); + ui->recurringEndDateEdit->setDate(ui->recurringEndDateEdit->minimumDate()); // Set to special value (Never) ui->recurringAmountSpin->setValue(0.0); ui->recurringAccountCombo->setCurrentText(""); ui->recurringCategoryCombo->setCurrentText(""); @@ -957,6 +1323,11 @@ void CashFlow::clearRecurringEntry() { void CashFlow::loadRecurringToEntry(const RecurringRule &r) { ui->recurringNameEdit->setText(r.name); ui->recurringStartDateEdit->setDate(r.startDate); + if (r.endDate.isValid()) { + ui->recurringEndDateEdit->setDate(r.endDate); + } else { + ui->recurringEndDateEdit->setDate(ui->recurringEndDateEdit->minimumDate()); // Show "Never" + } ui->recurringAmountSpin->setValue(r.amount); ui->recurringAccountCombo->setCurrentText(r.account); ui->recurringCategoryCombo->setCurrentText(r.category); @@ -1132,6 +1503,132 @@ void CashFlow::onOpenFile() { } } +void CashFlow::onSaveAs() { + if (currentFilePath.isEmpty()) { + QMessageBox::warning(this, "No File Open", "Please open or create a file first."); + return; + } + + QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QDir().mkpath(defaultDir); + + QString fileName = QFileDialog::getSaveFileName( + this, + "Save As", + defaultDir, + "CashFlo Files (*.cashflo.sqlite);;All Files (*)" + ); + + if (fileName.isEmpty()) { + return; + } + + // Ensure .cashflo.sqlite extension + if (!fileName.endsWith(".cashflo.sqlite", Qt::CaseInsensitive)) { + fileName += ".cashflo.sqlite"; + } + + // Don't allow overwriting the current file + if (QFileInfo(fileName).canonicalFilePath() == QFileInfo(currentFilePath).canonicalFilePath()) { + QMessageBox::warning(this, "Invalid Operation", "Cannot save as the same file. Use a different name."); + return; + } + + // Store the current file path + QString oldFilePath = currentFilePath; + + // Close database to unlock the file + delete database; + database = new Database(); + + // Copy the file + if (QFile::exists(fileName)) { + QFile::remove(fileName); + } + + if (!QFile::copy(oldFilePath, fileName)) { + QMessageBox::critical(this, "Error", "Failed to copy file."); + // Reopen the original file + openDatabase(oldFilePath); + return; + } + + // Open the new copy + if (!openDatabase(fileName)) { + QMessageBox::critical(this, "Error", "Failed to open copied file: " + database->lastError()); + // Try to reopen the original + openDatabase(oldFilePath); + } else { + QMessageBox::information(this, "Success", "File saved successfully."); + } +} + +void CashFlow::onExportCSV() { + QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + + QString fileName = QFileDialog::getSaveFileName( + this, + "Export to CSV", + defaultDir, + "CSV Files (*.csv);;All Files (*)" + ); + + if (fileName.isEmpty()) { + return; + } + + // Ensure .csv extension + if (!fileName.endsWith(".csv", Qt::CaseInsensitive)) { + fileName += ".csv"; + } + + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::critical(this, "Error", "Failed to open file for writing."); + return; + } + + QTextStream out(&file); + + // Write headers + int columnCount = ui->transactionTable->columnCount(); + QStringList headers; + for (int col = 0; col < columnCount; ++col) { + QString header = ui->transactionTable->horizontalHeaderItem(col)->text(); + // Escape quotes and wrap in quotes if needed + if (header.contains(',') || header.contains('"') || header.contains('\n')) { + header.replace("\"", "\"\""); + header = "\"" + header + "\""; + } + headers.append(header); + } + out << headers.join(",") << "\n"; + + // Write rows + int rowCount = ui->transactionTable->rowCount(); + for (int row = 0; row < rowCount; ++row) { + QStringList values; + for (int col = 0; col < columnCount; ++col) { + QString value; + QTableWidgetItem *item = ui->transactionTable->item(row, col); + if (item) { + value = item->text(); + } + + // Escape quotes and wrap in quotes if needed + if (value.contains(',') || value.contains('"') || value.contains('\n')) { + value.replace("\"", "\"\""); + value = "\"" + value + "\""; + } + values.append(value); + } + out << values.join(",") << "\n"; + } + + file.close(); + QMessageBox::information(this, "Success", QString("Exported %1 rows to CSV.").arg(rowCount)); +} + void CashFlow::onQuit() { QApplication::quit(); } @@ -1147,10 +1644,57 @@ void CashFlow::onPreferences() { } } +void CashFlow::onSearchTextChanged() { + refreshTransactionTable(); +} + +void CashFlow::onCollapseAll() { + // Mark all periods as collapsed for current period type + PeriodType periodType = static_cast<PeriodType>(ui->periodCombo->currentIndex()); + int rowCount = ui->transactionTable->rowCount(); + for (int row = 0; row < rowCount; ++row) { + QTableWidgetItem *item = ui->transactionTable->item(row, 0); + if (item && item->data(Qt::UserRole).toInt() == -1) { + // This is a period end row + int periodId = item->data(Qt::UserRole + 1).toInt(); + collapsedPeriods[periodType].insert(periodId); + } + } + refreshTransactionTable(); +} + +void CashFlow::onExpandAll() { + PeriodType periodType = static_cast<PeriodType>(ui->periodCombo->currentIndex()); + collapsedPeriods[periodType].clear(); + refreshTransactionTable(); +} + +void CashFlow::onTransactionTableDoubleClicked(int row, int column) { + Q_UNUSED(column); + QTableWidgetItem *item = ui->transactionTable->item(row, 0); + if (item && item->data(Qt::UserRole).toInt() == -1) { + // This is a period end row - toggle collapse state + PeriodType periodType = static_cast<PeriodType>(ui->periodCombo->currentIndex()); + int periodId = item->data(Qt::UserRole + 1).toInt(); + if (collapsedPeriods[periodType].contains(periodId)) { + collapsedPeriods[periodType].remove(periodId); + } else { + collapsedPeriods[periodType].insert(periodId); + } + refreshTransactionTable(); + } +} + void CashFlow::populateRecurringRulesCombo() { ui->entryRecurringCombo->clear(); ui->entryRecurringCombo->addItem("(None)", -1); + // Also populate the recurring filter combo + ui->recurringFilterCombo->blockSignals(true); + QString currentRecurringFilter = ui->recurringFilterCombo->currentText(); + ui->recurringFilterCombo->clear(); + ui->recurringFilterCombo->addItem("All Transactions", -1); + QList<RecurringRule> rules = database->getAllRecurringRules(); for (const RecurringRule &rule : rules) { QString label = QString("%1 (%2)").arg(rule.name).arg( @@ -1161,7 +1705,15 @@ void CashFlow::populateRecurringRulesCombo() { rule.frequency == RecurrenceFrequency::Yearly ? "Yearly" : "Unknown" ); ui->entryRecurringCombo->addItem(label, rule.id); + ui->recurringFilterCombo->addItem(label, rule.id); + } + + // Restore previous recurring filter selection if possible + int filterIndex = ui->recurringFilterCombo->findText(currentRecurringFilter); + if (filterIndex >= 0) { + ui->recurringFilterCombo->setCurrentIndex(filterIndex); } + ui->recurringFilterCombo->blockSignals(false); } QString CashFlow::generateOccurrenceKey(const QDate &date, RecurrenceFrequency frequency) const { |
