aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2025-12-31 13:46:44 -0500
committerCalvin Morrison <calvin@pobox.com>2025-12-31 13:46:44 -0500
commita6e05ddd0add4500877ceb2df69ea3e0d5ca9b15 (patch)
tree0c1f788b943cb26b9ec13bf77312061fa27cf72e
parent5cf763ea3ba2a89acfa5f24422cc71e0ff7fb35b (diff)
Fix estimated transaction edit persistence - keep recurring link and set reconciled flag
-rw-r--r--TODO.md104
-rw-r--r--cashflow.cpp650
-rw-r--r--cashflow.h26
-rw-r--r--cashflow.ui321
-rw-r--r--database.cpp27
5 files changed, 959 insertions, 169 deletions
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..3ef0700
--- /dev/null
+++ b/TODO.md
@@ -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 {
diff --git a/cashflow.h b/cashflow.h
index f76e11f..4bc0e7b 100644
--- a/cashflow.h
+++ b/cashflow.h
@@ -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);
}