package name.abuchen.portfolio.ui.views; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; import org.eclipse.jface.action.Action; import org.eclipse.jface.action.ActionContributionItem; import org.eclipse.jface.action.IMenuManager; import org.eclipse.jface.action.MenuManager; import org.eclipse.jface.action.Separator; import org.eclipse.jface.layout.TableColumnLayout; import org.eclipse.jface.viewers.ArrayContentProvider; import org.eclipse.jface.viewers.ColumnLabelProvider; import org.eclipse.jface.viewers.ColumnViewerToolTipSupport; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.viewers.StructuredSelection; import org.eclipse.jface.viewers.TableViewer; import org.eclipse.jface.window.ToolTip; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.CTabFolder; import org.eclipse.swt.custom.CTabItem; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.ToolBar; import org.swtchart.ISeries; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransaction.Type; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyConverterImpl; import name.abuchen.portfolio.money.ExchangeRateProviderFactory; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MutableMoney; import name.abuchen.portfolio.money.Quote; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.snapshot.AccountSnapshot; import name.abuchen.portfolio.ui.Images; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.PortfolioPart; import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransactionDialog; import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransferDialog; import name.abuchen.portfolio.ui.dialogs.transactions.OpenDialogAction; import name.abuchen.portfolio.ui.dialogs.transactions.SecurityTransactionDialog; import name.abuchen.portfolio.ui.util.AbstractDropDown; import name.abuchen.portfolio.ui.util.Colors; import name.abuchen.portfolio.ui.util.SimpleAction; import name.abuchen.portfolio.ui.util.chart.TimelineChart; import name.abuchen.portfolio.ui.util.viewers.Column; import name.abuchen.portfolio.ui.util.viewers.ColumnEditingSupport; import name.abuchen.portfolio.ui.util.viewers.ColumnEditingSupport.ModificationListener; import name.abuchen.portfolio.ui.util.viewers.ColumnViewerSorter; import name.abuchen.portfolio.ui.util.viewers.DateEditingSupport; import name.abuchen.portfolio.ui.util.viewers.SharesLabelProvider; import name.abuchen.portfolio.ui.util.viewers.ShowHideColumnHelper; import name.abuchen.portfolio.ui.util.viewers.StringEditingSupport; import name.abuchen.portfolio.ui.util.viewers.ValueEditingSupport; import name.abuchen.portfolio.ui.views.columns.CurrencyColumn; import name.abuchen.portfolio.ui.views.columns.CurrencyColumn.CurrencyEditingSupport; import name.abuchen.portfolio.ui.views.columns.NameColumn; import name.abuchen.portfolio.ui.views.columns.NameColumn.NameColumnLabelProvider; import name.abuchen.portfolio.ui.views.columns.NoteColumn; public class AccountListView extends AbstractListView implements ModificationListener { private static final String FILTER_INACTIVE_ACCOUNTS = "filter-redired-accounts"; //$NON-NLS-1$ private TableViewer accounts; private TableViewer transactions; private TimelineChart accountBalanceChart; /** * Store current balance of account after given transaction has been * applied. See {@link #updateBalance(Account)}. Do not store transient * balance in persistent AccountTransaction object. */ private Map<AccountTransaction, Money> transaction2balance = new HashMap<>(); private AccountContextMenu accountMenu = new AccountContextMenu(this); private ShowHideColumnHelper accountColumns; private ShowHideColumnHelper transactionsColumns; private boolean isFiltered = false; @Inject private ExchangeRateProviderFactory factory; @Override protected String getDefaultTitle() { return Messages.LabelAccounts; } @Override public void init(PortfolioPart part, Object parameter) { super.init(part, parameter); isFiltered = part.getPreferenceStore().getBoolean(FILTER_INACTIVE_ACCOUNTS); } private void resetInput() { accounts.setInput(isFiltered ? getClient().getActiveAccounts() : getClient().getAccounts()); } @Override protected void addButtons(ToolBar toolBar) { addNewButton(toolBar); addFilterButton(toolBar); addConfigButton(toolBar); } private void addNewButton(ToolBar toolBar) { SimpleAction.Runnable newAccountAction = a -> { Account account = new Account(); account.setName(Messages.LabelNoName); account.setCurrencyCode(getClient().getBaseCurrency()); getClient().addAccount(account); markDirty(); resetInput(); accounts.editElement(account, 0); }; AbstractDropDown.create(toolBar, Messages.MenuCreateAccountOrTransaction, Images.PLUS.image(), SWT.NONE, (dd, manager) -> { manager.add(new SimpleAction(Messages.AccountMenuAdd, newAccountAction)); manager.add(new Separator()); Account account = (Account) accounts.getStructuredSelection().getFirstElement(); new AccountContextMenu(AccountListView.this).menuAboutToShow(manager, account, null); }); } private void addFilterButton(ToolBar toolBar) { Action filter = new Action() { @Override public void run() { isFiltered = !isFiltered; getPart().getPreferenceStore().setValue(FILTER_INACTIVE_ACCOUNTS, isFiltered); setImageDescriptor(isFiltered ? Images.FILTER_ON.descriptor() : Images.FILTER_OFF.descriptor()); resetInput(); } }; filter.setImageDescriptor(isFiltered ? Images.FILTER_ON.descriptor() : Images.FILTER_OFF.descriptor()); filter.setToolTipText(Messages.AccountFilterRetiredAccounts); new ActionContributionItem(filter).fill(toolBar, -1); } private void addConfigButton(final ToolBar toolBar) { new AbstractDropDown(toolBar, Messages.MenuShowHideColumns, Images.CONFIG.image(), SWT.NONE) // NOSONAR { @Override public void menuAboutToShow(IMenuManager manager) { MenuManager m = new MenuManager(Messages.LabelAccounts); accountColumns.menuAboutToShow(m); manager.add(m); m = new MenuManager(Messages.LabelTransactions); transactionsColumns.menuAboutToShow(m); manager.add(m); } }; } @Override public void notifyModelUpdated() { resetInput(); Account account = (Account) ((IStructuredSelection) accounts.getSelection()).getFirstElement(); if (getClient().getAccounts().contains(account)) accounts.setSelection(new StructuredSelection(account)); else accounts.setSelection(StructuredSelection.EMPTY); } @Override public void onModified(Object element, Object newValue, Object oldValue) { if (element instanceof AccountTransaction) { AccountTransaction t = (AccountTransaction) element; if (t.getCrossEntry() != null) t.getCrossEntry().updateFrom(t); accounts.refresh(true); updateOnAccountSelected((Account) transactions.getData(Account.class.toString())); } markDirty(); } // ////////////////////////////////////////////////////////////// // top table: accounts // ////////////////////////////////////////////////////////////// @Override protected void createTopTable(Composite parent) { Composite container = new Composite(parent, SWT.NONE); TableColumnLayout layout = new TableColumnLayout(); container.setLayout(layout); accounts = new TableViewer(container, SWT.FULL_SELECTION); ColumnEditingSupport.prepare(accounts); accountColumns = new ShowHideColumnHelper(AccountListView.class.getSimpleName() + "@top2", //$NON-NLS-1$ getPreferenceStore(), accounts, layout); Column column = new NameColumn("0", Messages.ColumnAccount, SWT.None, 150); //$NON-NLS-1$ column.setLabelProvider(new NameColumnLabelProvider() // NOSONAR { @Override public Color getForeground(Object e) { boolean isRetired = ((Account) e).isRetired(); return isRetired ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; } }); column.getEditingSupport().addListener(this); accountColumns.addColumn(column); column = new Column(Messages.ColumnBalance, SWT.RIGHT, 80); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { return Values.Amount.format(((Account) e).getCurrentAmount()); } }); ColumnViewerSorter.create(Account.class, "currentAmount").attachTo(column); //$NON-NLS-1$ accountColumns.addColumn(column); column = new CurrencyColumn(); column.setEditingSupport(new CurrencyEditingSupport() { @Override public boolean canEdit(Object element) { return ((Account) element).getTransactions().isEmpty(); } }); accountColumns.addColumn(column); column = new NoteColumn(); column.getEditingSupport().addListener(this); accountColumns.addColumn(column); accountColumns.createColumns(); accounts.getTable().setHeaderVisible(true); accounts.getTable().setLinesVisible(true); accounts.setContentProvider(ArrayContentProvider.getInstance()); resetInput(); accounts.refresh(); accounts.addSelectionChangedListener(event -> { Account account = (Account) ((IStructuredSelection) event.getSelection()).getFirstElement(); updateOnAccountSelected(account); transactions.setData(Account.class.toString(), account); transactions.setInput(account != null ? account.getTransactions() : new ArrayList<AccountTransaction>(0)); transactions.refresh(); }); hookContextMenu(accounts.getTable(), this::fillAccountsContextMenu); } private void fillAccountsContextMenu(IMenuManager manager) // NOSONAR { final Account account = (Account) ((IStructuredSelection) accounts.getSelection()).getFirstElement(); if (account == null) return; accountMenu.menuAboutToShow(manager, account, null); manager.add(new Separator()); manager.add(new Action(account.isRetired() ? Messages.AccountMenuActivate : Messages.AccountMenuDeactivate) { @Override public void run() { account.setRetired(!account.isRetired()); markDirty(); resetInput(); } }); manager.add(new Action(Messages.AccountMenuDelete) { @Override public void run() { getClient().removeAccount(account); markDirty(); resetInput(); } }); } // ////////////////////////////////////////////////////////////// // bottom table: transactions // ////////////////////////////////////////////////////////////// @Override protected void createBottomTable(Composite parent) { // folder CTabFolder folder = new CTabFolder(parent, SWT.BORDER); CTabItem item = new CTabItem(folder, SWT.NONE); item.setText(Messages.TabTransactions); item.setControl(createTransactionTable(folder)); item = new CTabItem(folder, SWT.NONE); item.setText(Messages.TabAccountBalanceChart); item.setControl(createAccountBalanceChart(folder)); folder.setSelection(0); if (accounts.getTable().getItemCount() > 0) accounts.setSelection(new StructuredSelection(accounts.getElementAt(0)), true); } protected Control createTransactionTable(Composite parent) { Composite container = new Composite(parent, SWT.NONE); TableColumnLayout layout = new TableColumnLayout(); container.setLayout(layout); transactions = new TableViewer(container, SWT.FULL_SELECTION | SWT.MULTI); ColumnViewerToolTipSupport.enableFor(transactions, ToolTip.NO_RECREATE); ColumnEditingSupport.prepare(transactions); transactionsColumns = new ShowHideColumnHelper(AccountListView.class.getSimpleName() + "@bottom5", //$NON-NLS-1$ getPreferenceStore(), transactions, layout); Column column = new Column(Messages.ColumnDate, SWT.None, 80); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { AccountTransaction t = (AccountTransaction) e; return Values.Date.format(t.getDate()); } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); ColumnViewerSorter.create(new AccountTransaction.ByDateAmountTypeAndHashCode()).attachTo(column, SWT.DOWN); new DateEditingSupport(AccountTransaction.class, "date").addListener(this).attachTo(column); //$NON-NLS-1$ transactionsColumns.addColumn(column); column = new Column(Messages.ColumnTransactionType, SWT.None, 100); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { AccountTransaction t = (AccountTransaction) e; return t.getType().toString(); } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); column.setSorter(ColumnViewerSorter.create(AccountTransaction.class, "type")); //$NON-NLS-1$ transactionsColumns.addColumn(column); column = new Column(Messages.ColumnAmount, SWT.RIGHT, 80); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { AccountTransaction t = (AccountTransaction) e; long v = t.getAmount(); if (t.getType().isDebit()) v = -v; return Values.Money.format(Money.of(t.getCurrencyCode(), v), getClient().getBaseCurrency()); } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); column.setSorter(ColumnViewerSorter.create(AccountTransaction.class, "amount")); //$NON-NLS-1$ transactionsColumns.addColumn(column); column = new Column(Messages.Balance, SWT.RIGHT, 80); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { Money balance = transaction2balance.get(e); return balance != null ? Values.Money.format(balance, getClient().getBaseCurrency()) : null; } }); column.setSorter(ColumnViewerSorter.create(new AccountTransaction.ByDateAmountTypeAndHashCode())); transactionsColumns.addColumn(column); column = new Column(Messages.ColumnSecurity, SWT.None, 250); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { AccountTransaction t = (AccountTransaction) e; return t.getSecurity() != null ? String.valueOf(t.getSecurity()) : null; } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); column.setSorter(ColumnViewerSorter.create(AccountTransaction.class, "security")); //$NON-NLS-1$ transactionsColumns.addColumn(column); column = new Column(Messages.ColumnShares, SWT.RIGHT, 80); column.setLabelProvider(new SharesLabelProvider() { @Override public Long getValue(Object e) { AccountTransaction t = (AccountTransaction) e; if (t.getCrossEntry() instanceof BuySellEntry) { return ((BuySellEntry) t.getCrossEntry()).getPortfolioTransaction().getShares(); } else if (t.getType() == Type.DIVIDENDS && t.getShares() != 0) { return t.getShares(); } else { return null; } } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); new ValueEditingSupport(AccountTransaction.class, "shares", Values.Share) //$NON-NLS-1$ { @Override public boolean canEdit(Object element) { AccountTransaction t = (AccountTransaction) element; return t.getType() == AccountTransaction.Type.DIVIDENDS; } }.addListener(this).attachTo(column); transactionsColumns.addColumn(column); column = new Column(Messages.ColumnPerShare, SWT.RIGHT, 80); column.setDescription(Messages.ColumnPerShare_Description); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { AccountTransaction t = (AccountTransaction) e; if (t.getCrossEntry() instanceof BuySellEntry) { PortfolioTransaction pt = ((BuySellEntry) t.getCrossEntry()).getPortfolioTransaction(); return Values.Quote.format(pt.getGrossPricePerShare(), getClient().getBaseCurrency()); } else if (t.getType() == Type.DIVIDENDS && t.getShares() != 0) { long dividendPerShare = Math.round(t.getAmount() * Values.Share.divider() * Values.Quote.factorToMoney() / t.getShares()); return Values.Quote.format(Quote.of(t.getCurrencyCode(), dividendPerShare), getClient().getBaseCurrency()); } else { return null; } } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); transactionsColumns.addColumn(column); column = new Column(Messages.ColumnOffsetAccount, SWT.None, 120); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { AccountTransaction t = (AccountTransaction) e; return t.getCrossEntry() != null ? t.getCrossEntry().getCrossOwner(t).toString() : null; } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } }); transactionsColumns.addColumn(column); column = new Column(Messages.ColumnNote, SWT.None, 200); column.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object e) { return ((AccountTransaction) e).getNote(); } @Override public Color getForeground(Object element) { return colorFor((AccountTransaction) element); } @Override public Image getImage(Object e) { String note = ((AccountTransaction) e).getNote(); return note != null && note.length() > 0 ? Images.NOTE.image() : null; } }); ColumnViewerSorter.create(AccountTransaction.class, "note").attachTo(column); //$NON-NLS-1$ new StringEditingSupport(AccountTransaction.class, "note").addListener(this).attachTo(column); //$NON-NLS-1$ transactionsColumns.addColumn(column); transactionsColumns.createColumns(); transactions.getTable().setHeaderVisible(true); transactions.getTable().setLinesVisible(true); transactions.setContentProvider(ArrayContentProvider.getInstance()); hookContextMenu(transactions.getTable(), this::fillTransactionsContextMenu); hookKeyListener(); return container; } private Color colorFor(AccountTransaction t) { if (t.getType().isDebit()) return Display.getCurrent().getSystemColor(SWT.COLOR_DARK_RED); else return Display.getCurrent().getSystemColor(SWT.COLOR_DARK_GREEN); } private void hookKeyListener() { transactions.getControl().addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { if (e.keyCode == 'e' && e.stateMask == SWT.MOD1) { Account account = (Account) transactions.getData(Account.class.toString()); AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); if (account != null && transaction != null) createEditAction(account, transaction).run(); } } }); } private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR { Account account = (Account) transactions.getData(Account.class.toString()); if (account == null) return; AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions.getSelection()) .getFirstElement(); if (transaction != null) { Action action = createEditAction(account, transaction); action.setAccelerator(SWT.MOD1 | 'E'); manager.add(action); manager.add(new Separator()); } accountMenu.menuAboutToShow(manager, account, transaction != null ? transaction.getSecurity() : null); if (transaction != null) { manager.add(new Separator()); manager.add(new Action(Messages.AccountMenuDeleteTransaction) { @Override public void run() { Object[] selection = ((IStructuredSelection) transactions.getSelection()).toArray(); Account account = (Account) transactions.getData(Account.class.toString()); if (selection == null || selection.length == 0 || account == null) return; for (Object transaction : selection) account.deleteTransaction((AccountTransaction) transaction, getClient()); markDirty(); transaction2balance.clear(); updateBalance(account); accounts.refresh(); transactions.setInput(account.getTransactions()); } }); } } private Action createEditAction(Account account, AccountTransaction transaction) { // buy / sell if (transaction.getCrossEntry() instanceof BuySellEntry) { BuySellEntry entry = (BuySellEntry) transaction.getCrossEntry(); return new OpenDialogAction(this, Messages.MenuEditTransaction) .type(SecurityTransactionDialog.class, d -> d.setBuySellEntry(entry)) .parameters(entry.getPortfolioTransaction().getType()); } else if (transaction.getCrossEntry() instanceof AccountTransferEntry) { AccountTransferEntry entry = (AccountTransferEntry) transaction.getCrossEntry(); return new OpenDialogAction(this, Messages.MenuEditTransaction) // .type(AccountTransferDialog.class, d -> d.setEntry(entry)); } else { return new OpenDialogAction(this, Messages.MenuEditTransaction) // .type(AccountTransactionDialog.class, d -> d.setTransaction(account, transaction)) // .parameters(transaction.getType()); } } private Control createAccountBalanceChart(Composite parent) { accountBalanceChart = new TimelineChart(parent); accountBalanceChart.getTitle().setVisible(false); return accountBalanceChart; } private void updateOnAccountSelected(Account account) { updateBalance(account); updateChart(account); } private void updateBalance(Account account) { transaction2balance.clear(); if (account == null) return; List<AccountTransaction> tx = new ArrayList<>(account.getTransactions()); Collections.sort(tx, new AccountTransaction.ByDateAmountTypeAndHashCode()); MutableMoney balance = MutableMoney.of(account.getCurrencyCode()); for (AccountTransaction t : tx) { switch (t.getType()) { case DEPOSIT: case INTEREST: case DIVIDENDS: case TAX_REFUND: case SELL: case TRANSFER_IN: case FEES_REFUND: balance.add(t.getMonetaryAmount()); break; case REMOVAL: case FEES: case INTEREST_CHARGE: case TAXES: case BUY: case TRANSFER_OUT: balance.subtract(t.getMonetaryAmount()); break; default: throw new IllegalArgumentException(); } transaction2balance.put(t, balance.toMoney()); } } private void updateChart(Account account) { try { accountBalanceChart.suspendUpdate(true); for (ISeries s : accountBalanceChart.getSeriesSet().getSeries()) accountBalanceChart.getSeriesSet().deleteSeries(s.getId()); if (account == null) return; List<AccountTransaction> tx = account.getTransactions(); if (tx.isEmpty()) return; CurrencyConverter converter = new CurrencyConverterImpl(factory, account.getCurrencyCode()); Collections.sort(tx, new Transaction.ByDate()); LocalDate now = LocalDate.now(); LocalDate start = tx.get(0).getDate(); LocalDate end = tx.get(tx.size() - 1).getDate(); if (now.isAfter(end)) end = now; if (now.isBefore(start)) start = now; int days = (int) ChronoUnit.DAYS.between(start, end) + 2; LocalDate[] dates = new LocalDate[days]; double[] values = new double[days]; dates[0] = start.minusDays(1); values[0] = 0d; for (int ii = 1; ii < dates.length; ii++) { values[ii] = AccountSnapshot.create(account, converter, start) // .getFunds().getAmount() / Values.Amount.divider(); dates[ii] = start; start = start.plusDays(1); } accountBalanceChart.addDateSeries(dates, values, Colors.CASH, account.getName()); accountBalanceChart.adjustRange(); } finally { accountBalanceChart.suspendUpdate(false); } } }