File indexing completed on 2024-05-19 05:08:15

0001 /*
0002     SPDX-FileCopyrightText: 2000-2002 Michael Edwardes <mte@users.sourceforge.net>
0003     SPDX-FileCopyrightText: 2000-2002 Javier Campos Morales <javi_c@users.sourceforge.net>
0004     SPDX-FileCopyrightText: 2000-2002 Felix Rodriguez <frodriguez@users.sourceforge.net>
0005     SPDX-FileCopyrightText: 2000-2002 John C <thetacoturtle@users.sourceforge.net>
0006     SPDX-FileCopyrightText: 2000-2002 Thomas Baumgart <ipwizard@users.sourceforge.net>
0007     SPDX-FileCopyrightText: 2000-2002 Kevin Tambascio <ktambascio@users.sourceforge.net>
0008     SPDX-FileCopyrightText: 2017 Łukasz Wojniłowicz <lukasz.wojnilowicz@gmail.com>
0009     SPDX-FileCopyrightText: 2020 Robert Szczesiak <dev.rszczesiak@gmail.com>
0010     SPDX-License-Identifier: GPL-2.0-or-later
0011 */
0012 
0013 #include "kcategoriesview_p.h"
0014 
0015 #include <typeinfo>
0016 
0017 // ----------------------------------------------------------------------------
0018 // QT Includes
0019 
0020 #include <QBitArray>
0021 #include <QMenu>
0022 
0023 // ----------------------------------------------------------------------------
0024 // KDE Includes
0025 
0026 #include <KMessageBox>
0027 
0028 // ----------------------------------------------------------------------------
0029 // Project Includes
0030 
0031 #include "mymoneyfile.h"
0032 #include "mymoneyexception.h"
0033 #include "kmymoneysettings.h"
0034 #include "knewaccountdlg.h"
0035 #include "kcategoryreassigndlg.h"
0036 #include "mymoneyschedule.h"
0037 #include "mymoneybudget.h"
0038 #include "mymoneytransaction.h"
0039 #include "mymoneytransactionfilter.h"
0040 #include "mymoneyenums.h"
0041 #include "storageenums.h"
0042 #include "menuenums.h"
0043 #include "mymoneymoney.h"
0044 #include "accountdelegate.h"
0045 
0046 using namespace Icons;
0047 
0048 KCategoriesView::KCategoriesView(QWidget *parent) :
0049     KMyMoneyViewBase(*new KCategoriesViewPrivate(this), parent)
0050 {
0051     Q_D(KCategoriesView);
0052     d->init();
0053 
0054     connect(pActions[eMenu::Action::NewCategory],    &QAction::triggered, this, &KCategoriesView::slotNewCategory);
0055     connect(pActions[eMenu::Action::EditCategory],   &QAction::triggered, this, &KCategoriesView::slotEditCategory);
0056     connect(pActions[eMenu::Action::DeleteCategory], &QAction::triggered, this, &KCategoriesView::slotDeleteCategory);
0057 
0058     d->ui->m_accountTree->setItemDelegate(new AccountDelegate(d->ui->m_accountTree));
0059     connect(MyMoneyFile::instance()->accountsModel(), &AccountsModel::profitLossChanged, this, &KCategoriesView::slotProfitLossChanged);
0060 
0061     d->m_sharedToolbarActions.insert(eMenu::Action::FileNew, pActions[eMenu::Action::NewCategory]);
0062 
0063     d->ui->m_accountTree->setSelectionMode(QAbstractItemView::SingleSelection);
0064     d->ui->m_accountTree->setDragEnabled(true);
0065     d->ui->m_accountTree->setAcceptDrops(true);
0066     d->ui->m_accountTree->setDropIndicatorShown(true);
0067     d->ui->m_accountTree->setDragDropMode(QAbstractItemView::InternalMove);
0068 }
0069 
0070 KCategoriesView::~KCategoriesView()
0071 {
0072 }
0073 
0074 void KCategoriesView::slotSettingsChanged()
0075 {
0076     Q_D(KCategoriesView);
0077     d->m_proxyModel->setHideClosedAccounts(!KMyMoneySettings::showAllAccounts());
0078     d->m_proxyModel->setHideEquityAccounts(true);
0079     d->m_proxyModel->setHideZeroBalancedEquityAccounts(true);
0080     d->m_proxyModel->setShowAllEntries(KMyMoneySettings::showAllAccounts());
0081     d->m_proxyModel->setHideFavoriteAccounts(true);
0082 
0083     MyMoneyFile::instance()->accountsModel()->setColorScheme(AccountsModel::Positive, KMyMoneySettings::schemeColor(SchemeColor::Positive));
0084     MyMoneyFile::instance()->accountsModel()->setColorScheme(AccountsModel::Negative, KMyMoneySettings::schemeColor(SchemeColor::Negative));
0085 }
0086 
0087 
0088 void KCategoriesView::updateActions(const SelectedObjects& selections)
0089 {
0090     Q_D(KCategoriesView);
0091     const auto file = MyMoneyFile::instance();
0092 
0093     // check if there is anything todo and quit if not
0094     if (selections.selection(SelectedObjects::Account).count() < 1
0095             && d->m_currentCategory.id().isEmpty() ) {
0096         return;
0097     }
0098 
0099     pActions[eMenu::Action::NewCategory]->setEnabled(true);
0100     pActions[eMenu::Action::EditCategory]->setEnabled(false);
0101     pActions[eMenu::Action::DeleteCategory]->setEnabled(false);
0102 
0103     const auto accountIds = selections.selection(SelectedObjects::Account);
0104     if (accountIds.isEmpty()) {
0105         d->m_currentCategory = MyMoneyAccount();
0106         return;
0107     }
0108     const auto acc = file->accountsModel()->itemById(accountIds.at(0));
0109     auto b = file->isStandardAccount(acc.id()) ? false : true;
0110 
0111     QBitArray skip((int)eStorage::Reference::Count);
0112     switch (acc.accountType()) {
0113     case eMyMoney::Account::Type::Income:
0114     case eMyMoney::Account::Type::Expense:
0115         d->m_currentCategory = acc;
0116         pActions[eMenu::Action::EditCategory]->setEnabled(b);
0117 
0118         // enable delete action, if category/account itself is not referenced
0119         // by any object except accounts, because we want to allow
0120         // deleting of sub-categories. Also, we allow transactions, schedules and budgets
0121         // to be present because we can re-assign them during the delete process
0122         skip.fill(false);
0123         skip.setBit((int)eStorage::Reference::Transaction);
0124         skip.setBit((int)eStorage::Reference::Account);
0125         skip.setBit((int)eStorage::Reference::Schedule);
0126         skip.setBit((int)eStorage::Reference::Budget);
0127         pActions[eMenu::Action::DeleteCategory]->setEnabled(b && !file->isReferenced(acc, skip));
0128         break;
0129 
0130     default:
0131         d->m_currentCategory = MyMoneyAccount();
0132         break;
0133     }
0134 }
0135 
0136 /**
0137   * The view is notified that an unused income expense account has been hidden.
0138   */
0139 void KCategoriesView::slotUnusedIncomeExpenseAccountHidden()
0140 {
0141     Q_D(KCategoriesView);
0142     d->m_haveUnusedCategories = true;
0143     d->ui->m_hiddenCategories->setVisible(d->m_haveUnusedCategories);
0144 }
0145 
0146 void KCategoriesView::slotProfitLossChanged(const MyMoneyMoney &profit, bool isApproximate)
0147 {
0148     Q_D(KCategoriesView);
0149     const auto formattedValue = profit.isNegative() ? d->formatViewLabelValue(-profit, KMyMoneySettings::schemeColor(SchemeColor::Negative))
0150                                 : d->formatViewLabelValue(profit, KMyMoneySettings::schemeColor(SchemeColor::Positive));
0151     if (profit.isNegative())
0152         d->updateViewLabel(d->ui->m_totalProfitsLabel,
0153                            isApproximate ? i18nc("Approximate loss", "Loss: ~%1", formattedValue)
0154                            : i18n("Loss: %1", formattedValue));
0155     else
0156         d->updateViewLabel(d->ui->m_totalProfitsLabel,
0157                            isApproximate ? i18nc("Approximate profit", "Profit: ~%1", formattedValue)
0158                            : i18n("Profit: %1", formattedValue));
0159 }
0160 
0161 void KCategoriesView::slotNewCategory()
0162 {
0163     Q_D(KCategoriesView);
0164     MyMoneyAccount parent;
0165     MyMoneyAccount account;
0166 
0167     // Preselect the parent account by looking at the current selected account/category
0168     if (!d->m_currentCategory.id().isEmpty() &&
0169             d->m_currentCategory.isIncomeExpense()) {
0170         try {
0171             parent = MyMoneyFile::instance()->account(d->m_currentCategory.id());
0172         } catch (const MyMoneyException &) {
0173         }
0174     }
0175 
0176     KNewAccountDlg::createCategory(account, parent);
0177 }
0178 
0179 void KCategoriesView::slotEditCategory()
0180 {
0181     Q_D(KCategoriesView);
0182     if (d->m_currentCategory.id().isEmpty())
0183         return;
0184 
0185     const auto file = MyMoneyFile::instance();
0186     if (file->isStandardAccount(d->m_currentCategory.id()))
0187         return;
0188 
0189     switch (d->m_currentCategory.accountType()) {
0190     case eMyMoney::Account::Type::Income:
0191     case eMyMoney::Account::Type::Expense:
0192         break;
0193     default:
0194         return;
0195     }
0196 
0197     QPointer<KNewAccountDlg> dlg =
0198         new KNewAccountDlg(d->m_currentCategory, true, true, 0, i18n("Edit category '%1'", d->m_currentCategory.name()));
0199 
0200     dlg->setOpeningBalanceShown(false);
0201     dlg->setOpeningDateShown(false);
0202 
0203     if (dlg->exec() == QDialog::Accepted) {
0204         if (dlg != nullptr) {
0205             try {
0206                 MyMoneyFileTransaction ft(i18nc("Undo action description", "Edit category"), false);
0207 
0208                 auto account = dlg->account();
0209                 auto parent = dlg->parentAccount();
0210 
0211                 // we need to modify first, as reparent would override all other changes
0212                 file->modifyAccount(account);
0213                 if (account.parentAccountId() != parent.id())
0214                     file->reparentAccount(account, parent);
0215 
0216                 ft.commit();
0217                 // update our local copy of the category data
0218                 d->m_currentCategory = account;
0219             } catch (const MyMoneyException& e) {
0220                 KMessageBox::error(this, i18n("Unable to modify category '%1'. Cause: %2", d->m_currentCategory.name(), QString::fromLatin1(e.what())));
0221             }
0222         }
0223     }
0224 
0225     delete dlg;
0226 }
0227 
0228 void KCategoriesView::slotDeleteCategory()
0229 {
0230     Q_D(KCategoriesView);
0231     if (d->m_currentCategory.id().isEmpty())
0232         return;  // need an account ID
0233 
0234     const auto file = MyMoneyFile::instance();
0235     // can't delete standard accounts or account which still have transactions assigned
0236     if (file->isStandardAccount(d->m_currentCategory.id()))
0237         return;
0238 
0239     // check if the account is referenced by a transaction or schedule
0240     QBitArray skip((int)eStorage::Reference::Count);
0241     skip.fill(false);
0242     skip.setBit((int)eStorage::Reference::Account);
0243     skip.setBit((int)eStorage::Reference::Institution);
0244     skip.setBit((int)eStorage::Reference::Payee);
0245     skip.setBit((int)eStorage::Reference::Tag);
0246     skip.setBit((int)eStorage::Reference::Security);
0247     skip.setBit((int)eStorage::Reference::Currency);
0248     skip.setBit((int)eStorage::Reference::Price);
0249     const auto hasReference = file->isReferenced(d->m_currentCategory, skip);
0250 
0251     // if we get here and still have transactions referencing the account, we
0252     // need to check with the user to possibly re-assign them to a different account
0253     auto needAskUser = true;
0254 
0255     MyMoneyFileTransaction ft;
0256 
0257     if (hasReference) {
0258         // show transaction reassignment dialog
0259 
0260         needAskUser = false;
0261         auto dlg = new KCategoryReassignDlg(this);
0262         auto categoryId = dlg->show(d->m_currentCategory);
0263         delete dlg; // and kill the dialog
0264         if (categoryId.isEmpty())
0265             return; // the user aborted the dialog, so let's abort as well
0266 
0267         auto newCategory = file->account(categoryId);
0268         try {
0269             {
0270                 /*
0271                   d->m_currentCategory.id() is the old id, categoryId the new one
0272                   Now search all transactions and schedules that reference d->m_currentCategory.id()
0273                   and replace that with categoryId.
0274                 */
0275                 // get the list of all transactions that reference the old account
0276                 MyMoneyTransactionFilter filter(d->m_currentCategory.id());
0277                 filter.setReportAllSplits(false);
0278                 QList<MyMoneyTransaction> tlist;
0279                 QList<MyMoneyTransaction>::iterator it_t;
0280                 file->transactionList(tlist, filter);
0281 
0282                 for (it_t = tlist.begin(); it_t != tlist.end(); ++it_t) {
0283                     MyMoneyTransaction t = (*it_t);
0284                     if (t.replaceId(categoryId, d->m_currentCategory.id()))
0285                         file->modifyTransaction(t);
0286                 }
0287             }
0288             // now fix all schedules
0289             {
0290                 QList<MyMoneySchedule> slist = file->scheduleList(d->m_currentCategory.id());
0291                 QList<MyMoneySchedule>::iterator it_s;
0292 
0293                 for (it_s = slist.begin(); it_s != slist.end(); ++it_s) {
0294                     MyMoneySchedule sch = (*it_s);
0295                     if (sch.replaceId(categoryId, d->m_currentCategory.id())) {
0296                         file->modifySchedule(sch);
0297                     }
0298                 }
0299             }
0300             // now fix all budgets
0301             {
0302                 QList<MyMoneyBudget> blist = file->budgetList();
0303                 QList<MyMoneyBudget>::const_iterator it_b;
0304                 for (it_b = blist.constBegin(); it_b != blist.constEnd(); ++it_b) {
0305                     if ((*it_b).hasReferenceTo(d->m_currentCategory.id())) {
0306                         MyMoneyBudget b = (*it_b);
0307                         MyMoneyBudget::AccountGroup fromBudget = b.account(d->m_currentCategory.id());
0308                         MyMoneyBudget::AccountGroup toBudget = b.account(categoryId);
0309                         toBudget += fromBudget;
0310                         b.setAccount(toBudget, categoryId);
0311                         b.removeReference(d->m_currentCategory.id());
0312                         file->modifyBudget(b);
0313                     }
0314                 }
0315             }
0316         } catch (MyMoneyException &e) {
0317             KMessageBox::error(this, i18n("Unable to exchange category <b>%1</b> with category <b>%2</b>. Reason: %3", d->m_currentCategory.name(), newCategory.name(), QString::fromLatin1(e.what())));
0318             return;
0319         }
0320     }
0321 
0322     // retain the account name for a possible later usage in the error message box
0323     // since the account removal notifies the views the selected account can be changed
0324     // so we make sure by doing this that we display the correct name in the error message
0325     auto selectedAccountName = d->m_currentCategory.name();
0326 
0327     // at this point, we must not have a reference to the account
0328     // to be deleted anymore
0329     // special handling for categories to allow deleting of empty subcategories
0330     {
0331         // open a compound statement here to be able to declare variables
0332         // which would otherwise not work within a case label.
0333 
0334         // case A - only a single, unused category without subcats selected
0335         if (d->m_currentCategory.accountList().isEmpty()) {
0336             if (!needAskUser
0337                 || (KMessageBox::questionTwoActions(this,
0338                                                     i18n("<qt>Do you really want to delete category <b>%1</b>?</qt>", selectedAccountName),
0339                                                     i18nc("@title:window", "Delete category"),
0340                                                     KMMYesNo::yes(),
0341                                                     KMMYesNo::no())
0342                     == KMessageBox::PrimaryAction)) {
0343                 try {
0344                     file->removeAccount(d->m_currentCategory);
0345                     d->m_currentCategory.clearId();
0346                     ft.commit();
0347                 } catch (const MyMoneyException &e) {
0348                     KMessageBox::error(this, i18n("<qt>Unable to delete category <b>%1</b>. Cause: %2</qt>", selectedAccountName, QString::fromLatin1(e.what())));
0349                 }
0350             }
0351             return;
0352         }
0353         // case B - we have some subcategories, maybe the user does not want to
0354         //          delete them all, but just the category itself?
0355         auto parentAccount = file->account(d->m_currentCategory.parentAccountId());
0356 
0357         QStringList accountsToReparent;
0358         int result = KMessageBox::questionTwoActionsCancel(this,
0359                                                            i18n("<qt>Do you want to delete category <b>%1</b> with all its sub-categories or only "
0360                                                                 "the category itself? If you only delete the category itself, all its sub-categories "
0361                                                                 "will be made sub-categories of <b>%2</b>.</qt>",
0362                                                                 selectedAccountName,
0363                                                                 parentAccount.name()),
0364                                                            QString(),
0365                                                            KGuiItem(i18n("Delete all")),
0366                                                            KGuiItem(i18n("Just the category")));
0367         if (result == KMessageBox::Cancel)
0368             return; // cancel pressed? ok, no delete then...
0369         // "No" means "Just the category" and that means we need to reparent all subaccounts
0370         bool need_confirmation = false;
0371         // case C - User only wants to delete the category itself
0372         if (result == KMessageBox::SecondaryAction)
0373             accountsToReparent = d->m_currentCategory.accountList();
0374         else {
0375             // case D - User wants to delete all subcategories, now check all subcats of
0376             //          d->m_currentCategory and remember all that cannot be deleted and
0377             //          must be "reparented"
0378             const auto subAccountList = d->m_currentCategory.accountList();
0379             for (const auto& accountID : qAsConst(subAccountList)) {
0380                 // reparent account if a transaction is assigned
0381                 if (file->transactionCount(accountID) != 0)
0382                     accountsToReparent.push_back(accountID);
0383                 else if (!file->account(accountID).accountList().isEmpty()) {
0384                     // or if we have at least one sub-account that is used for transactions
0385                     if (!file->hasOnlyUnusedAccounts(file->account(accountID).accountList())) {
0386                         accountsToReparent.push_back(accountID);
0387                         //qDebug() << "subaccount not empty";
0388                     }
0389                 }
0390             }
0391             if (!accountsToReparent.isEmpty())
0392                 need_confirmation = true;
0393         }
0394         if (!accountsToReparent.isEmpty() && need_confirmation) {
0395             if (KMessageBox::questionTwoActions(this,
0396                                                 i18n("<p>Some sub-categories of category <b>%1</b> cannot "
0397                                                      "be deleted, because they are still used. They will be made sub-categories of <b>%2</b>. Proceed?</p>",
0398                                                      selectedAccountName,
0399                                                      parentAccount.name()),
0400                                                 i18nc("@title:window", "Delete category"),
0401                                                 KMMYesNo::yes(),
0402                                                 KMMYesNo::no())
0403                 != KMessageBox::PrimaryAction) {
0404                 return; // user gets wet feet...
0405             }
0406         }
0407         // all good, now first reparent selected sub-categories
0408         try {
0409             auto parent = file->account(d->m_currentCategory.parentAccountId());
0410             for (QStringList::const_iterator it = accountsToReparent.constBegin(); it != accountsToReparent.constEnd(); ++it) {
0411                 auto child = file->account(*it);
0412                 file->reparentAccount(child, parent);
0413             }
0414             // reload the account because the sub-account list might have changed
0415             d->m_currentCategory = file->account(d->m_currentCategory.id());
0416             // now recursively delete remaining sub-categories
0417             file->removeAccountList(d->m_currentCategory.accountList());
0418             // don't forget to update d->m_currentCategory, because we still have a copy of
0419             // the old account list, which is no longer valid
0420             d->m_currentCategory = file->account(d->m_currentCategory.id());
0421         } catch (const MyMoneyException &e) {
0422             KMessageBox::error(this, i18n("<qt>Unable to delete a sub-category of category <b>%1</b>. Reason: %2</qt>", selectedAccountName, QString::fromLatin1(e.what())));
0423             return;
0424         }
0425     }
0426     // the category/account is deleted after the switch
0427     try {
0428         file->removeAccount(d->m_currentCategory);
0429         d->m_currentCategory.clearId();
0430         ft.commit();
0431     } catch (const MyMoneyException &e) {
0432         KMessageBox::error(this, i18n("Unable to delete category '%1'. Cause: %2", selectedAccountName, QString::fromLatin1(e.what())));
0433     }
0434 }