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 | |
| parent | 5cf763ea3ba2a89acfa5f24422cc71e0ff7fb35b (diff) | |
Fix estimated transaction edit persistence - keep recurring link and set reconciled flag
| -rw-r--r-- | TODO.md | 104 | ||||
| -rw-r--r-- | cashflow.cpp | 650 | ||||
| -rw-r--r-- | cashflow.h | 26 | ||||
| -rw-r--r-- | cashflow.ui | 321 | ||||
| -rw-r--r-- | database.cpp | 27 |
5 files changed, 959 insertions, 169 deletions
@@ -0,0 +1,104 @@ +# CashFlo - TODO & Roadmap + +## Planned Features + +### Transfers +- [ ] Implement account-to-account transfers +- [ ] Create `transfers` table with transfer metadata +- [ ] Add `transfer_id` column to transactions table (FK) +- [ ] UI: Transfer dialog (from/to account, amount, date) +- [ ] Create paired transactions automatically +- [ ] Cascade delete when transfer is removed +- [ ] Visual indicator in transaction grid (⇄ symbol) +- [ ] Exclude transfers from spending reports + +**Design Decision:** Use separate `transfers` table (Option C): +```sql +transfers table: +- id INTEGER PRIMARY KEY +- date TEXT +- amount REAL +- from_account TEXT +- to_account TEXT +- description TEXT + +transactions table: +- transfer_id INTEGER (nullable, FK to transfers) +``` + +### Transaction Management +- [ ] Drag-and-drop reordering of transactions within same date + - Use `sort_order` field that already exists in database + - Visual feedback: highlight valid drop zone (same date only) + - Update sort_order values when dropped + - Prevents dragging across different dates + - Shows where transaction will be inserted + +### User Experience & Documentation +- [ ] Comprehensive tooltips on all buttons/fields +- [ ] Empty state messages ("No transactions yet...") +- [ ] Status bar contextual help +- [ ] Welcome dialog on first run +- [ ] Keyboard shortcut cheat sheet (Help menu) +- [ ] Sample data / example budget loader +- [ ] User guide / documentation page + +### Debt & Loan Tracking +- [ ] Highlight when recurring payments end (debt payoff visualization) +- [ ] Show "cash flow improvement" when debts are paid off +- [ ] "Days until debt-free" counter/view +- [ ] Account types: Asset vs Liability +- [ ] Net worth dashboard (Assets - Liabilities) + +### AI Integration +- [ ] MCP server for AI assistant integration +- [ ] HTTP REST API in Qt app (QHttpServer) +- [ ] Endpoints: GET/POST transactions, recurring rules +- [ ] Python MCP server that calls REST API +- [ ] Enable AI to import bank statements, categorize transactions +- [ ] "What-if" scenario analysis via AI queries + +### Scenario Planning / What-If Analysis +- [ ] "New Scenario Copy" menu item (enhanced Save As) +- [ ] Optional: Toggle checkboxes on recurring rules (enable/disable in projections) +- [ ] Optional: Named scenarios with side-by-side comparison + +## Ideas for Discussion +- Double-entry bookkeeping? (Probably overkill for personal finance) +- Split transactions? (e.g., loan payment = principal + interest) +- CSV import wizard +- Budget categories with spending limits +- Reports and charts +- Mobile app / web interface + +## Completed Features +- ✅ Transaction management (add, edit, delete) +- ✅ Recurring rules with multiple frequencies +- ✅ Projection generation +- ✅ Reconciliation system with checkpoints +- ✅ Date range filtering +- ✅ Running balance calculations +- ✅ Save As (for creating copies) +- ✅ Export to CSV +- ✅ Multi-filter system (account, recurring rule, search text) +- ✅ Period collapse/expand functionality +- ✅ Escape key to deselect +- ✅ Sortable recurring rules table with numeric sorting +- ✅ Delete preserves selection (moves to next row) + + +### UI + +1. need better interface for editing, perhaps on the right hand side? ideally it needs to be fast. Quick Entry/Edit is REQUIRED for a good tool. right now its a-lada clickin + +currency input needs to be awesome, like a mask that has + +shown: $ -13,000.00 +typed: 13000 + +a little smarter right? + +same for date dialogs and stuff, it all needs to be very quick and keyboardy. + +2. need to make the grid editable / and saving. To say, change a tx from estimated to actual or change the date etc. + 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 { @@ -19,6 +19,9 @@ public: CashFlow(QWidget *parent = nullptr); ~CashFlow(); +protected: + void keyPressEvent(QKeyEvent *event) override; + private slots: void onDateRangeChanged(); void onTransactionSelected(); @@ -32,13 +35,26 @@ private slots: void onPeriodChanged(); void onNewFile(); void onOpenFile(); + void onSaveAs(); + void onExportCSV(); void onQuit(); void onPreferences(); void onRecurringRuleChanged(); void onTransactionDateChanged(); void onCreateAdjustment(); + void onSearchTextChanged(); + void onCollapseAll(); + void onExpandAll(); + void onTransactionTableDoubleClicked(int row, int column); private: + enum PeriodType { + Daily, + Weekly, + Monthly, + Quarterly + }; + Ui::CashFlow *ui; Database *database; int currentTransactionId; @@ -47,13 +63,7 @@ private: QFont currentAmountFont; int weekStartDay; QString currentFilePath; - - enum PeriodType { - Daily, - Weekly, - Monthly, - Quarterly - }; + QMap<PeriodType, QSet<int>> collapsedPeriods; // Track which period end rows are collapsed per period type void setupConnections(); void refreshView(); @@ -68,7 +78,7 @@ private: QDate getPeriodEnd(const QDate &date, PeriodType periodType); QDate getPeriodStart(const QDate &date, PeriodType periodType); QString getPeriodLabel(const QDate &date, PeriodType periodType, int count); - void insertPeriodEndRow(const QString &label, double balance, const QMap<QString, double> &accountBalances); + void insertPeriodEndRow(const QString &label, double balance, const QMap<QString, double> &accountBalances, int periodId); void updateAmountColors(); void loadSettings(); QString formatCurrency(double amount) const; diff --git a/cashflow.ui b/cashflow.ui index c69afdb..7f35845 100644 --- a/cashflow.ui +++ b/cashflow.ui @@ -20,111 +20,184 @@ <property name="title"> <string>Date Range</string> </property> - <layout class="QHBoxLayout" name="horizontalLayout"> + <layout class="QVBoxLayout" name="verticalLayout_2"> <item> - <widget class="QLabel" name="label"> - <property name="text"> - <string>From:</string> - </property> - </widget> - </item> - <item> - <widget class="QDateEdit" name="dateFromEdit"> - <property name="calendarPopup"> - <bool>true</bool> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_2"> - <property name="text"> - <string>To:</string> - </property> - </widget> - </item> - <item> - <widget class="QDateEdit" name="dateToEdit"> - <property name="calendarPopup"> - <bool>true</bool> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_14"> - <property name="text"> - <string>Period:</string> - </property> - </widget> - </item> - <item> - <widget class="QComboBox" name="periodCombo"> + <layout class="QHBoxLayout" name="horizontalLayout"> <item> - <property name="text"> - <string>Daily</string> - </property> + <widget class="QLabel" name="label"> + <property name="text"> + <string>From:</string> + </property> + </widget> </item> <item> - <property name="text"> - <string>Weekly</string> - </property> + <widget class="QDateEdit" name="dateFromEdit"> + <property name="calendarPopup"> + <bool>true</bool> + </property> + </widget> </item> <item> - <property name="text"> - <string>Monthly</string> - </property> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>To:</string> + </property> + </widget> </item> <item> - <property name="text"> - <string>Quarterly</string> - </property> + <widget class="QDateEdit" name="dateToEdit"> + <property name="calendarPopup"> + <bool>true</bool> + </property> + </widget> </item> - </widget> - </item> - <item> - <widget class="QLabel" name="label_15"> - <property name="text"> - <string>Account:</string> - </property> - </widget> - </item> - <item> - <widget class="QComboBox" name="accountFilterCombo"> <item> - <property name="text"> - <string>All Accounts</string> - </property> + <widget class="QLabel" name="label_14"> + <property name="text"> + <string>Period:</string> + </property> + </widget> </item> - </widget> - </item> - <item> - <widget class="QCheckBox" name="showAccountBalancesCheck"> - <property name="text"> - <string>Show Account Balances</string> - </property> - <property name="checked"> - <bool>false</bool> - </property> - </widget> + <item> + <widget class="QComboBox" name="periodCombo"> + <item> + <property name="text"> + <string>Daily</string> + </property> + </item> + <item> + <property name="text"> + <string>Weekly</string> + </property> + </item> + <item> + <property name="text"> + <string>Monthly</string> + </property> + </item> + <item> + <property name="text"> + <string>Quarterly</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_15"> + <property name="text"> + <string>Account:</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="accountFilterCombo"> + <item> + <property name="text"> + <string>All Accounts</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_16"> + <property name="text"> + <string>Recurring:</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="recurringFilterCombo"> + <item> + <property name="text"> + <string>All Transactions</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QCheckBox" name="showAccountBalancesCheck"> + <property name="text"> + <string>Show Account Balances</string> + </property> + <property name="checked"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> </item> <item> - <spacer name="horizontalSpacer"> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - <property name="sizeHint" stdset="0"> - <size> - <width>40</width> - <height>20</height> - </size> - </property> - </spacer> + <layout class="QHBoxLayout" name="horizontalLayout_search"> + <item> + <widget class="QLabel" name="label_search"> + <property name="text"> + <string>Search:</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="searchEdit"> + <property name="placeholderText"> + <string>Filter transactions...</string> + </property> + <property name="clearButtonEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="collapseAllBtn"> + <property name="text"> + <string>Collapse All</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="expandAllBtn"> + <property name="text"> + <string>Expand All</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> </item> - <item> - <widget class="QLabel" name="startBalanceLabel"> - <property name="text"> - <string>Starting Balance: $0.00</string> - </property> - <property name="styleSheet"> + </layout> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_balance"> + <item> + <widget class="QLabel" name="startBalanceLabel"> + <property name="text"> + <string>Starting Balance: $0.00</string> + </property> + <property name="styleSheet"> <string notr="true">font-weight: bold; font-size: 12pt;</string> </property> </widget> @@ -140,7 +213,6 @@ </widget> </item> </layout> - </widget> </item> <item> <widget class="QTabWidget" name="tabWidget"> @@ -366,6 +438,9 @@ <property name="selectionBehavior"> <enum>QAbstractItemView::SelectRows</enum> </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> </widget> </item> <item> @@ -435,13 +510,30 @@ </widget> </item> <item row="1" column="2"> + <widget class="QLabel" name="label_enddate"> + <property name="text"> + <string>End Date:</string> + </property> + </widget> + </item> + <item row="1" column="3"> + <widget class="QDateEdit" name="recurringEndDateEdit"> + <property name="calendarPopup"> + <bool>true</bool> + </property> + <property name="specialValueText"> + <string>Never</string> + </property> + </widget> + </item> + <item row="2" column="0"> <widget class="QLabel" name="label_11"> <property name="text"> <string>Amount:</string> </property> </widget> </item> - <item row="1" column="3"> + <item row="2" column="1"> <widget class="QDoubleSpinBox" name="recurringAmountSpin"> <property name="buttonSymbols"> <enum>QAbstractSpinBox::NoButtons</enum> @@ -460,42 +552,42 @@ </property> </widget> </item> - <item row="2" column="0"> - <widget class="QLabel" name="label_12"> + <item row="2" column="2"> + <widget class="QLabel" name="label_category"> <property name="text"> - <string>Account:</string> + <string>Category:</string> </property> </widget> </item> - <item row="2" column="1"> - <widget class="QComboBox" name="recurringAccountCombo"> + <item row="2" column="3"> + <widget class="QComboBox" name="recurringCategoryCombo"> <property name="editable"> <bool>true</bool> </property> </widget> </item> - <item row="2" column="2"> - <widget class="QLabel" name="label_17"> + <item row="3" column="0"> + <widget class="QLabel" name="label_12"> <property name="text"> - <string>Category:</string> + <string>Account:</string> </property> </widget> </item> - <item row="2" column="3"> - <widget class="QComboBox" name="recurringCategoryCombo"> + <item row="3" column="1"> + <widget class="QComboBox" name="recurringAccountCombo"> <property name="editable"> <bool>true</bool> </property> </widget> </item> - <item row="3" column="0"> + <item row="3" column="2"> <widget class="QLabel" name="label_13"> <property name="text"> <string>Description:</string> </property> </widget> </item> - <item row="3" column="1" colspan="3"> + <item row="3" column="3"> <widget class="QLineEdit" name="recurringDescriptionEdit"/> </item> <item row="4" column="0" colspan="2"> @@ -557,6 +649,9 @@ </property> <addaction name="actionNew"/> <addaction name="actionOpen"/> + <addaction name="actionSaveAs"/> + <addaction name="separator"/> + <addaction name="actionExportCSV"/> <addaction name="separator"/> <addaction name="actionQuit"/> </widget> @@ -602,6 +697,22 @@ <string>Ctrl+,</string> </property> </action> + <action name="actionSaveAs"> + <property name="text"> + <string>Save As...</string> + </property> + <property name="shortcut"> + <string>Ctrl+Shift+S</string> + </property> + </action> + <action name="actionExportCSV"> + <property name="text"> + <string>Export to CSV...</string> + </property> + <property name="shortcut"> + <string>Ctrl+E</string> + </property> + </action> </widget> <resources/> <connections/> diff --git a/database.cpp b/database.cpp index 1610536..cc5fa7d 100644 --- a/database.cpp +++ b/database.cpp @@ -482,19 +482,31 @@ void Database::regenerateProjectionsForRule(const RecurringRule &rule) { break; } - // Check if an actual transaction already exists for this date and rule + // Generate occurrence key for this date + QString occurrenceKey; + if (rule.frequency == RecurrenceFrequency::Daily) { + occurrenceKey = currentDate.toString("yyyy-MM-dd"); + } else if (rule.frequency == RecurrenceFrequency::Weekly || rule.frequency == RecurrenceFrequency::BiWeekly) { + occurrenceKey = QString("%1-W%2").arg(currentDate.year()).arg(currentDate.weekNumber(), 2, 10, QChar('0')); + } else if (rule.frequency == RecurrenceFrequency::Monthly) { + occurrenceKey = currentDate.toString("yyyy-MM"); + } else if (rule.frequency == RecurrenceFrequency::Yearly) { + occurrenceKey = QString::number(currentDate.year()); + } + + // Check if this occurrence already has a transaction (actual or reconciled) QSqlQuery checkQuery(db); - checkQuery.prepare("SELECT COUNT(*) FROM transactions WHERE recurring_id = :rid AND date = :date AND type = 'actual'"); + checkQuery.prepare("SELECT COUNT(*) FROM transactions WHERE recurring_id = :rid AND occurrence_key = :okey AND (type = 'actual' OR reconciled = 1)"); checkQuery.bindValue(":rid", rule.id); - checkQuery.bindValue(":date", currentDate.toString(Qt::ISODate)); + checkQuery.bindValue(":okey", occurrenceKey); - bool hasActual = false; + bool hasTransaction = false; if (checkQuery.exec() && checkQuery.next()) { - hasActual = checkQuery.value(0).toInt() > 0; + hasTransaction = checkQuery.value(0).toInt() > 0; } - // Only create projection if no actual exists - if (!hasActual) { + // Only create projection if this occurrence hasn't been fulfilled + if (!hasTransaction) { Transaction t; t.date = currentDate; t.amount = rule.amount; @@ -505,6 +517,7 @@ void Database::regenerateProjectionsForRule(const RecurringRule &rule) { t.recurringId = rule.id; t.reconciled = false; t.sortOrder = rule.sortOrder; + t.occurrenceKey = occurrenceKey; addTransaction(t); } |
