[Mobile] Add banners to alert users of recommended budget actions (#4643)
* Add banners to alert users of recommended budget actions * Update wording for consistency * Release notes * Fix release notes * Code review feedback * Cleanup * Extend playwright timeout * Update Categorize button locator in test * Update VRT * Dummy commit * Streamline cover spending flow * VRT * Remove category from modal when covered and close modal when all categories are covered * Coderabbit suggestions * Update translations * VRT --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -144,7 +144,7 @@ export class MobileNavigation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async goToUncategorizedPage() {
|
async goToUncategorizedPage() {
|
||||||
const button = this.page.getByRole('button', { name: /uncategorized/ });
|
const button = this.page.getByRole('button', { name: 'Categorize' });
|
||||||
await button.click();
|
await button.click();
|
||||||
|
|
||||||
return new MobileAccountPage(this.page);
|
return new MobileAccountPage(this.page);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
|
|||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
timeout: 45000, // 45 seconds
|
timeout: 60000, // 60 seconds
|
||||||
retries: 1,
|
retries: 1,
|
||||||
testDir: 'e2e/',
|
testDir: 'e2e/',
|
||||||
reporter: !process.env.CI ? [['html', { open: 'never' }]] : undefined,
|
reporter: !process.env.CI ? [['html', { open: 'never' }]] : undefined,
|
||||||
|
|||||||
@@ -92,49 +92,48 @@ export function Modals() {
|
|||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
const modals = modalStack
|
const modals = modalStack
|
||||||
.map(modal => {
|
.map((modal, idx) => {
|
||||||
const { name } = modal;
|
const { name } = modal;
|
||||||
|
const key = `${name}-${idx}`;
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'goal-templates':
|
case 'goal-templates':
|
||||||
return budgetId ? <GoalTemplateModal key={name} /> : null;
|
return budgetId ? <GoalTemplateModal key={key} /> : null;
|
||||||
|
|
||||||
case 'keyboard-shortcuts':
|
case 'keyboard-shortcuts':
|
||||||
// don't show the hotkey help modal when a budget is not open
|
// don't show the hotkey help modal when a budget is not open
|
||||||
return budgetId ? <KeyboardShortcutModal key={name} /> : null;
|
return budgetId ? <KeyboardShortcutModal key={key} /> : null;
|
||||||
|
|
||||||
case 'import-transactions':
|
case 'import-transactions':
|
||||||
return <ImportTransactionsModal key={name} {...modal.options} />;
|
return <ImportTransactionsModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'add-account':
|
case 'add-account':
|
||||||
return <CreateAccountModal key={name} {...modal.options} />;
|
return <CreateAccountModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'add-local-account':
|
case 'add-local-account':
|
||||||
return <CreateLocalAccountModal key={name} />;
|
return <CreateLocalAccountModal key={key} />;
|
||||||
|
|
||||||
case 'close-account':
|
case 'close-account':
|
||||||
return <CloseAccountModal key={name} {...modal.options} />;
|
return <CloseAccountModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'select-linked-accounts':
|
case 'select-linked-accounts':
|
||||||
return <SelectLinkedAccountsModal key={name} {...modal.options} />;
|
return <SelectLinkedAccountsModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'confirm-category-delete':
|
case 'confirm-category-delete':
|
||||||
return <ConfirmCategoryDeleteModal key={name} {...modal.options} />;
|
return <ConfirmCategoryDeleteModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'confirm-unlink-account':
|
case 'confirm-unlink-account':
|
||||||
return <ConfirmUnlinkAccountModal key={name} {...modal.options} />;
|
return <ConfirmUnlinkAccountModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'confirm-transaction-edit':
|
case 'confirm-transaction-edit':
|
||||||
return <ConfirmTransactionEditModal key={name} {...modal.options} />;
|
return <ConfirmTransactionEditModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'confirm-transaction-delete':
|
case 'confirm-transaction-delete':
|
||||||
return (
|
return <ConfirmTransactionDeleteModal key={key} {...modal.options} />;
|
||||||
<ConfirmTransactionDeleteModal key={name} {...modal.options} />
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'load-backup':
|
case 'load-backup':
|
||||||
return (
|
return (
|
||||||
<LoadBackupModal
|
<LoadBackupModal
|
||||||
key={name}
|
key={key}
|
||||||
watchUpdates
|
watchUpdates
|
||||||
{...modal.options}
|
{...modal.options}
|
||||||
backupDisabled={false}
|
backupDisabled={false}
|
||||||
@@ -142,27 +141,27 @@ export function Modals() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'manage-rules':
|
case 'manage-rules':
|
||||||
return <ManageRulesModal key={name} {...modal.options} />;
|
return <ManageRulesModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'edit-rule':
|
case 'edit-rule':
|
||||||
return <EditRuleModal key={name} {...modal.options} />;
|
return <EditRuleModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'merge-unused-payees':
|
case 'merge-unused-payees':
|
||||||
return <MergeUnusedPayeesModal key={name} {...modal.options} />;
|
return <MergeUnusedPayeesModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'gocardless-init':
|
case 'gocardless-init':
|
||||||
return <GoCardlessInitialiseModal key={name} {...modal.options} />;
|
return <GoCardlessInitialiseModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'simplefin-init':
|
case 'simplefin-init':
|
||||||
return <SimpleFinInitialiseModal key={name} {...modal.options} />;
|
return <SimpleFinInitialiseModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'pluggyai-init':
|
case 'pluggyai-init':
|
||||||
return <PluggyAiInitialiseModal key={name} {...modal.options} />;
|
return <PluggyAiInitialiseModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'gocardless-external-msg':
|
case 'gocardless-external-msg':
|
||||||
return (
|
return (
|
||||||
<GoCardlessExternalMsgModal
|
<GoCardlessExternalMsgModal
|
||||||
key={name}
|
key={key}
|
||||||
{...modal.options}
|
{...modal.options}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
modal.options.onClose?.();
|
modal.options.onClose?.();
|
||||||
@@ -172,73 +171,73 @@ export function Modals() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'create-encryption-key':
|
case 'create-encryption-key':
|
||||||
return <CreateEncryptionKeyModal key={name} {...modal.options} />;
|
return <CreateEncryptionKeyModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'fix-encryption-key':
|
case 'fix-encryption-key':
|
||||||
return <FixEncryptionKeyModal key={name} {...modal.options} />;
|
return <FixEncryptionKeyModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'edit-field':
|
case 'edit-field':
|
||||||
return <EditFieldModal key={name} {...modal.options} />;
|
return <EditFieldModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'category-autocomplete':
|
case 'category-autocomplete':
|
||||||
return <CategoryAutocompleteModal key={name} {...modal.options} />;
|
return <CategoryAutocompleteModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'account-autocomplete':
|
case 'account-autocomplete':
|
||||||
return <AccountAutocompleteModal key={name} {...modal.options} />;
|
return <AccountAutocompleteModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'payee-autocomplete':
|
case 'payee-autocomplete':
|
||||||
return <PayeeAutocompleteModal key={name} {...modal.options} />;
|
return <PayeeAutocompleteModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'payee-category-learning':
|
case 'payee-category-learning':
|
||||||
return <CategoryLearning key={name} />;
|
return <CategoryLearning key={key} />;
|
||||||
|
|
||||||
case 'new-category':
|
case 'new-category':
|
||||||
return <NewCategoryModal key={name} {...modal.options} />;
|
return <NewCategoryModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'new-category-group':
|
case 'new-category-group':
|
||||||
return <NewCategoryGroupModal key={name} {...modal.options} />;
|
return <NewCategoryGroupModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'envelope-budget-summary':
|
case 'envelope-budget-summary':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<EnvelopeBudgetSummaryModal key={name} {...modal.options} />
|
<EnvelopeBudgetSummaryModal key={key} {...modal.options} />
|
||||||
</NamespaceContext.Provider>
|
</NamespaceContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'tracking-budget-summary':
|
case 'tracking-budget-summary':
|
||||||
return <TrackingBudgetSummaryModal key={name} {...modal.options} />;
|
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'schedule-edit':
|
case 'schedule-edit':
|
||||||
return <ScheduleDetails key={name} {...modal.options} />;
|
return <ScheduleDetails key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'schedule-link':
|
case 'schedule-link':
|
||||||
return <ScheduleLink key={name} {...modal.options} />;
|
return <ScheduleLink key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'schedules-discover':
|
case 'schedules-discover':
|
||||||
return <DiscoverSchedules key={name} />;
|
return <DiscoverSchedules key={key} />;
|
||||||
|
|
||||||
case 'schedules-upcoming-length':
|
case 'schedules-upcoming-length':
|
||||||
return <UpcomingLength key={name} />;
|
return <UpcomingLength key={key} />;
|
||||||
|
|
||||||
case 'schedule-posts-offline-notification':
|
case 'schedule-posts-offline-notification':
|
||||||
return <PostsOfflineNotification key={name} />;
|
return <PostsOfflineNotification key={key} />;
|
||||||
|
|
||||||
case 'synced-account-edit':
|
case 'synced-account-edit':
|
||||||
return <EditSyncAccount key={name} {...modal.options} />;
|
return <EditSyncAccount key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'account-menu':
|
case 'account-menu':
|
||||||
return <AccountMenuModal key={name} {...modal.options} />;
|
return <AccountMenuModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'category-menu':
|
case 'category-menu':
|
||||||
return <CategoryMenuModal key={name} {...modal.options} />;
|
return <CategoryMenuModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'envelope-budget-menu':
|
case 'envelope-budget-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<EnvelopeBudgetMenuModal {...modal.options} />
|
<EnvelopeBudgetMenuModal {...modal.options} />
|
||||||
@@ -248,7 +247,7 @@ export function Modals() {
|
|||||||
case 'tracking-budget-menu':
|
case 'tracking-budget-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<TrackingBudgetMenuModal {...modal.options} />
|
<TrackingBudgetMenuModal {...modal.options} />
|
||||||
@@ -256,15 +255,15 @@ export function Modals() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'category-group-menu':
|
case 'category-group-menu':
|
||||||
return <CategoryGroupMenuModal key={name} {...modal.options} />;
|
return <CategoryGroupMenuModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'notes':
|
case 'notes':
|
||||||
return <NotesModal key={name} {...modal.options} />;
|
return <NotesModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'envelope-balance-menu':
|
case 'envelope-balance-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<EnvelopeBalanceMenuModal {...modal.options} />
|
<EnvelopeBalanceMenuModal {...modal.options} />
|
||||||
@@ -274,7 +273,7 @@ export function Modals() {
|
|||||||
case 'envelope-summary-to-budget-menu':
|
case 'envelope-summary-to-budget-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<EnvelopeToBudgetMenuModal {...modal.options} />
|
<EnvelopeToBudgetMenuModal {...modal.options} />
|
||||||
@@ -284,7 +283,7 @@ export function Modals() {
|
|||||||
case 'hold-buffer':
|
case 'hold-buffer':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<HoldBufferModal {...modal.options} />
|
<HoldBufferModal {...modal.options} />
|
||||||
@@ -294,7 +293,7 @@ export function Modals() {
|
|||||||
case 'tracking-balance-menu':
|
case 'tracking-balance-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<TrackingBalanceMenuModal {...modal.options} />
|
<TrackingBalanceMenuModal {...modal.options} />
|
||||||
@@ -302,23 +301,21 @@ export function Modals() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'transfer':
|
case 'transfer':
|
||||||
return <TransferModal key={name} {...modal.options} />;
|
return <TransferModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'cover':
|
case 'cover':
|
||||||
return <CoverModal key={name} {...modal.options} />;
|
return <CoverModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'scheduled-transaction-menu':
|
case 'scheduled-transaction-menu':
|
||||||
return (
|
return <ScheduledTransactionMenuModal key={key} {...modal.options} />;
|
||||||
<ScheduledTransactionMenuModal key={name} {...modal.options} />
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'budget-page-menu':
|
case 'budget-page-menu':
|
||||||
return <BudgetPageMenuModal key={name} {...modal.options} />;
|
return <BudgetPageMenuModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'envelope-budget-month-menu':
|
case 'envelope-budget-month-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<EnvelopeBudgetMonthMenuModal {...modal.options} />
|
<EnvelopeBudgetMonthMenuModal {...modal.options} />
|
||||||
@@ -328,7 +325,7 @@ export function Modals() {
|
|||||||
case 'tracking-budget-month-menu':
|
case 'tracking-budget-month-menu':
|
||||||
return (
|
return (
|
||||||
<NamespaceContext.Provider
|
<NamespaceContext.Provider
|
||||||
key={name}
|
key={key}
|
||||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||||
>
|
>
|
||||||
<TrackingBudgetMonthMenuModal {...modal.options} />
|
<TrackingBudgetMonthMenuModal {...modal.options} />
|
||||||
@@ -338,48 +335,48 @@ export function Modals() {
|
|||||||
case 'budget-file-selection':
|
case 'budget-file-selection':
|
||||||
return <BudgetFileSelectionModal key={name} />;
|
return <BudgetFileSelectionModal key={name} />;
|
||||||
case 'delete-budget':
|
case 'delete-budget':
|
||||||
return <DeleteFileModal key={name} {...modal.options} />;
|
return <DeleteFileModal key={key} {...modal.options} />;
|
||||||
case 'duplicate-budget':
|
case 'duplicate-budget':
|
||||||
return <DuplicateFileModal key={name} {...modal.options} />;
|
return <DuplicateFileModal key={key} {...modal.options} />;
|
||||||
case 'import':
|
case 'import':
|
||||||
return <ImportModal key={name} />;
|
return <ImportModal key={key} />;
|
||||||
case 'files-settings':
|
case 'files-settings':
|
||||||
return <FilesSettingsModal key={name} />;
|
return <FilesSettingsModal key={key} />;
|
||||||
case 'confirm-change-document-dir':
|
case 'confirm-change-document-dir':
|
||||||
return (
|
return <ConfirmChangeDocumentDirModal key={key} {...modal.options} />;
|
||||||
<ConfirmChangeDocumentDirModal key={name} {...modal.options} />
|
|
||||||
);
|
|
||||||
case 'import-ynab4':
|
case 'import-ynab4':
|
||||||
return <ImportYNAB4Modal key={name} />;
|
return <ImportYNAB4Modal key={key} />;
|
||||||
case 'import-ynab5':
|
case 'import-ynab5':
|
||||||
return <ImportYNAB5Modal key={name} />;
|
return <ImportYNAB5Modal key={key} />;
|
||||||
case 'import-actual':
|
case 'import-actual':
|
||||||
return <ImportActualModal key={name} />;
|
return <ImportActualModal key={key} />;
|
||||||
|
|
||||||
case 'out-of-sync-migrations':
|
case 'out-of-sync-migrations':
|
||||||
return <OutOfSyncMigrationsModal key={name} />;
|
return <OutOfSyncMigrationsModal key={key} />;
|
||||||
|
|
||||||
case 'edit-access':
|
case 'edit-access':
|
||||||
return <EditUserAccess key={name} {...modal.options} />;
|
return <EditUserAccess key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'edit-user':
|
case 'edit-user':
|
||||||
return <EditUserFinanceApp key={name} {...modal.options} />;
|
return <EditUserFinanceApp key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'transfer-ownership':
|
case 'transfer-ownership':
|
||||||
return <TransferOwnership key={name} {...modal.options} />;
|
return <TransferOwnership key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'enable-openid':
|
case 'enable-openid':
|
||||||
return <OpenIDEnableModal key={name} {...modal.options} />;
|
return <OpenIDEnableModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'enable-password-auth':
|
case 'enable-password-auth':
|
||||||
return <PasswordEnableModal key={name} {...modal.options} />;
|
return <PasswordEnableModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error('Unknown modal');
|
throw new Error('Unknown modal');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map((modal, idx) => (
|
.map((modal, idx) => (
|
||||||
<React.Fragment key={modalStack[idx].name}>{modal}</React.Fragment>
|
<React.Fragment key={`${modalStack[idx].name}-${idx}`}>
|
||||||
|
{modal}
|
||||||
|
</React.Fragment>
|
||||||
));
|
));
|
||||||
|
|
||||||
// fragment needed per TS types
|
// fragment needed per TS types
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type CommonAutocompleteProps<T extends Item> = {
|
|||||||
clearOnBlur?: boolean;
|
clearOnBlur?: boolean;
|
||||||
clearOnSelect?: boolean;
|
clearOnSelect?: boolean;
|
||||||
closeOnBlur?: boolean;
|
closeOnBlur?: boolean;
|
||||||
|
closeOnSelect?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,6 +231,7 @@ function SingleAutocomplete<T extends Item>({
|
|||||||
clearOnBlur = true,
|
clearOnBlur = true,
|
||||||
clearOnSelect = false,
|
clearOnSelect = false,
|
||||||
closeOnBlur = true,
|
closeOnBlur = true,
|
||||||
|
closeOnSelect = !clearOnSelect,
|
||||||
onClose,
|
onClose,
|
||||||
value: initialValue,
|
value: initialValue,
|
||||||
}: SingleAutocompleteProps<T>) {
|
}: SingleAutocompleteProps<T>) {
|
||||||
@@ -306,7 +308,9 @@ function SingleAutocomplete<T extends Item>({
|
|||||||
|
|
||||||
if (clearOnSelect) {
|
if (clearOnSelect) {
|
||||||
setValue('');
|
setValue('');
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (closeOnSelect) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +352,7 @@ function SingleAutocomplete<T extends Item>({
|
|||||||
Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem,
|
Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem,
|
||||||
// Do nothing if it is a "touch" selection event
|
// Do nothing if it is a "touch" selection event
|
||||||
Downshift.stateChangeTypes.touchEnd,
|
Downshift.stateChangeTypes.touchEnd,
|
||||||
|
Downshift.stateChangeTypes.mouseUp,
|
||||||
// @ts-expect-error Types say there is no type
|
// @ts-expect-error Types say there is no type
|
||||||
].includes(changes.type)
|
].includes(changes.type)
|
||||||
) {
|
) {
|
||||||
@@ -395,7 +400,8 @@ function SingleAutocomplete<T extends Item>({
|
|||||||
onStateChange={changes => {
|
onStateChange={changes => {
|
||||||
if (
|
if (
|
||||||
!clearOnBlur &&
|
!clearOnBlur &&
|
||||||
changes.type === Downshift.stateChangeTypes.mouseUp
|
(changes.type === Downshift.stateChangeTypes.mouseUp ||
|
||||||
|
changes.type === Downshift.stateChangeTypes.touchEnd)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ function CategoryItem({
|
|||||||
typeof balanceBinding
|
typeof balanceBinding
|
||||||
>(balanceBinding);
|
>(balanceBinding);
|
||||||
|
|
||||||
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
|
const isToBudgetItem = item.id === 'to-budget';
|
||||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
|
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -430,16 +430,13 @@ function CategoryItem({
|
|||||||
display: !showBalances ? 'none' : undefined,
|
display: !showBalances ? 'none' : undefined,
|
||||||
marginLeft: 5,
|
marginLeft: 5,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...makeAmountFullStyle(
|
...makeAmountFullStyle((isToBudgetItem ? toBudget : balance) || 0, {
|
||||||
(isToBeBudgetedItem ? toBudget : balance) || 0,
|
positiveColor: theme.noticeTextMenu,
|
||||||
{
|
negativeColor: theme.errorTextMenu,
|
||||||
positiveColor: theme.noticeTextMenu,
|
}),
|
||||||
negativeColor: theme.errorTextMenu,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isToBeBudgetedItem
|
{isToBudgetItem
|
||||||
? toBudget != null
|
? toBudget != null
|
||||||
? ` ${integerToCurrency(toBudget || 0)}`
|
? ` ${integerToCurrency(toBudget || 0)}`
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ import { getValidMonthBounds } from './MonthsContext';
|
|||||||
export function addToBeBudgetedGroup(groups: CategoryGroupEntity[]) {
|
export function addToBeBudgetedGroup(groups: CategoryGroupEntity[]) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'to-be-budgeted',
|
id: 'to-budget',
|
||||||
name: t('To Be Budgeted'),
|
name: t('To Budget'),
|
||||||
categories: [
|
categories: [
|
||||||
{
|
{
|
||||||
id: 'to-be-budgeted',
|
id: 'to-budget',
|
||||||
name: t('To Be Budgeted'),
|
name: t('To Budget'),
|
||||||
cat_group: 'to-be-budgeted',
|
cat_group: 'to-budget',
|
||||||
group: {
|
group: {
|
||||||
id: 'to-be-budgeted',
|
id: 'to-budget',
|
||||||
name: t('To Be Budgeted'),
|
name: t('To Budget'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React, { memo, useCallback, useRef } from 'react';
|
import React, {
|
||||||
import { useTranslation } from 'react-i18next';
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { GridList, GridListItem } from 'react-aria-components';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button } from '@actual-app/components/button';
|
import { Button } from '@actual-app/components/button';
|
||||||
import { Card } from '@actual-app/components/card';
|
import { Card } from '@actual-app/components/card';
|
||||||
@@ -12,7 +21,11 @@ import {
|
|||||||
SvgArrowThickRight,
|
SvgArrowThickRight,
|
||||||
SvgCheveronRight,
|
SvgCheveronRight,
|
||||||
} from '@actual-app/components/icons/v1';
|
} from '@actual-app/components/icons/v1';
|
||||||
import { SvgCalendar, SvgViewShow } from '@actual-app/components/icons/v2';
|
import {
|
||||||
|
SvgArrowButtonDown1,
|
||||||
|
SvgCalendar,
|
||||||
|
SvgViewShow,
|
||||||
|
} from '@actual-app/components/icons/v2';
|
||||||
import { Label } from '@actual-app/components/label';
|
import { Label } from '@actual-app/components/label';
|
||||||
import { styles } from '@actual-app/components/styles';
|
import { styles } from '@actual-app/components/styles';
|
||||||
import { Text } from '@actual-app/components/text';
|
import { Text } from '@actual-app/components/text';
|
||||||
@@ -28,6 +41,7 @@ import {
|
|||||||
trackingBudget,
|
trackingBudget,
|
||||||
uncategorizedCount,
|
uncategorizedCount,
|
||||||
} from 'loot-core/client/queries';
|
} from 'loot-core/client/queries';
|
||||||
|
import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider';
|
||||||
import * as monthUtils from 'loot-core/shared/months';
|
import * as monthUtils from 'loot-core/shared/months';
|
||||||
import { groupById, integerToCurrency } from 'loot-core/shared/util';
|
import { groupById, integerToCurrency } from 'loot-core/shared/util';
|
||||||
|
|
||||||
@@ -36,15 +50,16 @@ import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
|||||||
import { useLocale } from '../../../hooks/useLocale';
|
import { useLocale } from '../../../hooks/useLocale';
|
||||||
import { useLocalPref } from '../../../hooks/useLocalPref';
|
import { useLocalPref } from '../../../hooks/useLocalPref';
|
||||||
import { useNavigate } from '../../../hooks/useNavigate';
|
import { useNavigate } from '../../../hooks/useNavigate';
|
||||||
|
import { usePrevious } from '../../../hooks/usePrevious';
|
||||||
import { useSyncedPref } from '../../../hooks/useSyncedPref';
|
import { useSyncedPref } from '../../../hooks/useSyncedPref';
|
||||||
import { useUndo } from '../../../hooks/useUndo';
|
import { useUndo } from '../../../hooks/useUndo';
|
||||||
import { useDispatch } from '../../../redux';
|
import { useDispatch } from '../../../redux';
|
||||||
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
|
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
|
||||||
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
|
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
|
||||||
import { Link } from '../../common/Link';
|
|
||||||
import { MobilePageHeader, Page } from '../../Page';
|
import { MobilePageHeader, Page } from '../../Page';
|
||||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||||
import { CellValue } from '../../spreadsheet/CellValue';
|
import { CellValue } from '../../spreadsheet/CellValue';
|
||||||
|
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
|
||||||
import { useFormat } from '../../spreadsheet/useFormat';
|
import { useFormat } from '../../spreadsheet/useFormat';
|
||||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||||
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
|
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
|
||||||
@@ -112,7 +127,12 @@ function ToBudget({ toBudget, onPress, show3Cols }) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: amount < 0 ? theme.errorText : theme.formInputText,
|
color:
|
||||||
|
amount < 0
|
||||||
|
? theme.errorText
|
||||||
|
: amount > 0
|
||||||
|
? theme.noticeText
|
||||||
|
: theme.formInputText,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{format(value, type)}
|
{format(value, type)}
|
||||||
@@ -1043,38 +1063,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function UncategorizedButton() {
|
|
||||||
const count = useSheetValue(uncategorizedCount());
|
|
||||||
if (count === null || count <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
padding: 5,
|
|
||||||
paddingBottom: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
variant="button"
|
|
||||||
type="button"
|
|
||||||
buttonVariant="primary"
|
|
||||||
to="/accounts/uncategorized"
|
|
||||||
style={{
|
|
||||||
border: 0,
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
padding: '1.25em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
|
|
||||||
<View style={{ flex: 1 }} />
|
|
||||||
<SvgArrowThinRight width="15" height="15" />
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BudgetGroups({
|
function BudgetGroups({
|
||||||
type,
|
type,
|
||||||
categoryGroups,
|
categoryGroups,
|
||||||
@@ -1251,6 +1239,7 @@ export function BudgetTable({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Banners month={month} onBudgetAction={onBudgetAction} />
|
||||||
<BudgetTableHeader
|
<BudgetTableHeader
|
||||||
type={type}
|
type={type}
|
||||||
month={month}
|
month={month}
|
||||||
@@ -1267,7 +1256,6 @@ export function BudgetTable({
|
|||||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UncategorizedButton />
|
|
||||||
<BudgetGroups
|
<BudgetGroups
|
||||||
type={type}
|
type={type}
|
||||||
categoryGroups={categoryGroups}
|
categoryGroups={categoryGroups}
|
||||||
@@ -1294,6 +1282,387 @@ export function BudgetTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Banner({ type = 'info', children }) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
height: 50,
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor:
|
||||||
|
type === 'critical'
|
||||||
|
? theme.errorBackground
|
||||||
|
: type === 'warning'
|
||||||
|
? theme.warningBackground
|
||||||
|
: theme.noticeBackground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UncategorizedTransactionsBanner(props) {
|
||||||
|
const count = useSheetValue(uncategorizedCount());
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (count === null || count <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridListItem textValue="Uncategorized transactions banner" {...props}>
|
||||||
|
<Banner type="warning">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans count={count}>
|
||||||
|
You have {{ count }} uncategorized transactions
|
||||||
|
</Trans>
|
||||||
|
<Button
|
||||||
|
onPress={() => navigate('/accounts/uncategorized')}
|
||||||
|
style={PILL_STYLE}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
<Trans>Categorize</Trans>
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Banner>
|
||||||
|
</GridListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverbudgetedBanner({ month, onBudgetAction, ...props }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toBudgetAmount = useSheetValue(envelopeBudget.toBudget);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { showUndoNotification } = useUndo();
|
||||||
|
const { list: categories } = useCategories();
|
||||||
|
const categoriesById = groupById(categories);
|
||||||
|
|
||||||
|
const openCoverOverbudgetedModal = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'cover',
|
||||||
|
options: {
|
||||||
|
title: t('Cover overbudgeted'),
|
||||||
|
month,
|
||||||
|
showToBeBudgeted: false,
|
||||||
|
onSubmit: categoryId => {
|
||||||
|
onBudgetAction(month, 'cover-overbudgeted', {
|
||||||
|
category: categoryId,
|
||||||
|
});
|
||||||
|
showUndoNotification({
|
||||||
|
message: t('Covered overbudgeted from {{categoryName}}', {
|
||||||
|
categoryName: categoriesById[categoryId].name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
categoriesById,
|
||||||
|
dispatch,
|
||||||
|
month,
|
||||||
|
onBudgetAction,
|
||||||
|
showUndoNotification,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!toBudgetAmount || toBudgetAmount >= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridListItem textValue="Overbudgeted banner" {...props}>
|
||||||
|
<Banner type="critical">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SvgArrowButtonDown1 style={{ width: 15, height: 15 }} />
|
||||||
|
<Text>
|
||||||
|
<Trans>You have budgeted more than your available funds</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button onPress={openCoverOverbudgetedModal} style={PILL_STYLE}>
|
||||||
|
<Trans>Cover</Trans>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Banner>
|
||||||
|
</GridListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverspendingBanner({ month, onBudgetAction, ...props }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { list: categories, grouped: categoryGroups } = useCategories();
|
||||||
|
const categoriesById = groupById(categories);
|
||||||
|
|
||||||
|
const categoryBalanceBindings = useMemo(
|
||||||
|
() =>
|
||||||
|
categories.map(category => [
|
||||||
|
category.id,
|
||||||
|
envelopeBudget.catBalance(category.id),
|
||||||
|
]),
|
||||||
|
[categories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryCarryoverBindings = useMemo(
|
||||||
|
() =>
|
||||||
|
categories.map(category => [
|
||||||
|
category.id,
|
||||||
|
envelopeBudget.catCarryover(category.id),
|
||||||
|
]),
|
||||||
|
[categories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [overspentByCategory, setOverspentByCategory] = useState({});
|
||||||
|
const [carryoverFlagByCategory, setCarryoverFlagByCategory] = useState({});
|
||||||
|
const sheetName = useContext(NamespaceContext);
|
||||||
|
const spreadsheet = useSpreadsheet();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unbindList = [];
|
||||||
|
for (const [categoryId, carryoverBinding] of categoryCarryoverBindings) {
|
||||||
|
const unbind = spreadsheet.bind(sheetName, carryoverBinding, result => {
|
||||||
|
const isRolloverEnabled = Boolean(result.value);
|
||||||
|
if (isRolloverEnabled) {
|
||||||
|
setCarryoverFlagByCategory(prev => ({
|
||||||
|
...prev,
|
||||||
|
[categoryId]: result.value,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Update to remove covered category.
|
||||||
|
setCarryoverFlagByCategory(prev => {
|
||||||
|
const { [categoryId]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unbindList.push(unbind);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindList.forEach(unbind => unbind());
|
||||||
|
};
|
||||||
|
}, [categoryCarryoverBindings, sheetName, spreadsheet]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unbindList = [];
|
||||||
|
for (const [categoryId, balanceBinding] of categoryBalanceBindings) {
|
||||||
|
const unbind = spreadsheet.bind(sheetName, balanceBinding, result => {
|
||||||
|
if (result.value < 0) {
|
||||||
|
setOverspentByCategory(prev => ({
|
||||||
|
...prev,
|
||||||
|
[categoryId]: result.value,
|
||||||
|
}));
|
||||||
|
} else if (result.value === 0) {
|
||||||
|
// Update to remove covered category.
|
||||||
|
setOverspentByCategory(prev => {
|
||||||
|
const { [categoryId]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unbindList.push(unbind);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindList.forEach(unbind => unbind());
|
||||||
|
};
|
||||||
|
}, [categoryBalanceBindings, sheetName, spreadsheet]);
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Ignore those that has rollover enabled.
|
||||||
|
const overspentCategoryIds = Object.keys(overspentByCategory).filter(
|
||||||
|
id => !carryoverFlagByCategory[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryGroupsToShow = useMemo(
|
||||||
|
() =>
|
||||||
|
categoryGroups
|
||||||
|
.filter(g =>
|
||||||
|
g.categories?.some(c => overspentCategoryIds.includes(c.id)),
|
||||||
|
)
|
||||||
|
.map(g => ({
|
||||||
|
...g,
|
||||||
|
categories:
|
||||||
|
g.categories?.filter(c => overspentCategoryIds.includes(c.id)) ||
|
||||||
|
[],
|
||||||
|
})),
|
||||||
|
[categoryGroups, overspentCategoryIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showUndoNotification } = useUndo();
|
||||||
|
|
||||||
|
const onOpenCoverCategoryModal = useCallback(
|
||||||
|
categoryId => {
|
||||||
|
const category = categoriesById[categoryId];
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'cover',
|
||||||
|
options: {
|
||||||
|
title: category.name,
|
||||||
|
month,
|
||||||
|
categoryId: category.id,
|
||||||
|
onSubmit: fromCategoryId => {
|
||||||
|
onBudgetAction(month, 'cover-overspending', {
|
||||||
|
to: category.id,
|
||||||
|
from: fromCategoryId,
|
||||||
|
});
|
||||||
|
showUndoNotification({
|
||||||
|
message: t(
|
||||||
|
`Covered {{toCategoryName}} overspending from {{fromCategoryName}}.`,
|
||||||
|
{
|
||||||
|
toCategoryName: category.name,
|
||||||
|
fromCategoryName:
|
||||||
|
fromCategoryId === 'to-budget'
|
||||||
|
? 'To Budget'
|
||||||
|
: categoriesById[fromCategoryId].name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[categoriesById, dispatch, month, onBudgetAction, showUndoNotification, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onOpenCategorySelectionModal = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'category-autocomplete',
|
||||||
|
options: {
|
||||||
|
title: t('Cover overspending'),
|
||||||
|
month,
|
||||||
|
categoryGroups: categoryGroupsToShow,
|
||||||
|
onSelect: onOpenCoverCategoryModal,
|
||||||
|
clearOnSelect: true,
|
||||||
|
closeOnSelect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [categoryGroupsToShow, dispatch, month, onOpenCoverCategoryModal, t]);
|
||||||
|
|
||||||
|
const numberOfOverspentCategories = overspentCategoryIds.length;
|
||||||
|
const previousNumberOfOverspentCategories = usePrevious(
|
||||||
|
numberOfOverspentCategories,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (numberOfOverspentCategories < previousNumberOfOverspentCategories) {
|
||||||
|
// Re-render the modal when the overspent categories are covered.
|
||||||
|
dispatch(collapseModals({ rootModalName: 'category-autocomplete' }));
|
||||||
|
onOpenCategorySelectionModal();
|
||||||
|
|
||||||
|
// All overspent categories have been covered.
|
||||||
|
if (numberOfOverspentCategories === 0) {
|
||||||
|
dispatch(collapseModals({ rootModalName: 'category-autocomplete' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
dispatch,
|
||||||
|
onOpenCategorySelectionModal,
|
||||||
|
numberOfOverspentCategories,
|
||||||
|
previousNumberOfOverspentCategories,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (numberOfOverspentCategories === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridListItem textValue="Overspent banner" {...props}>
|
||||||
|
<Banner type="critical">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
<Trans count={numberOfOverspentCategories}>
|
||||||
|
You have {{ count: numberOfOverspentCategories }} overspent
|
||||||
|
categories
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button onPress={onOpenCategorySelectionModal} style={PILL_STYLE}>
|
||||||
|
<Trans>Cover</Trans>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Banner>
|
||||||
|
</GridListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Banners({ month, onBudgetAction }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||||
|
|
||||||
|
// Limit to rollover for now.
|
||||||
|
if (budgetType !== 'rollover') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridList
|
||||||
|
aria-label={t('Banners')}
|
||||||
|
style={{ backgroundColor: theme.mobilePageBackground }}
|
||||||
|
>
|
||||||
|
<UncategorizedTransactionsBanner />
|
||||||
|
<OverspendingBanner month={month} onBudgetAction={onBudgetAction} />
|
||||||
|
<OverbudgetedBanner month={month} onBudgetAction={onBudgetAction} />
|
||||||
|
</GridList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BudgetTableHeader({
|
function BudgetTableHeader({
|
||||||
show3Cols,
|
show3Cols,
|
||||||
type,
|
type,
|
||||||
@@ -1559,7 +1928,7 @@ function MonthSelector({
|
|||||||
}}
|
}}
|
||||||
style={{ ...arrowButtonStyle, opacity: prevEnabled ? 1 : 0.6 }}
|
style={{ ...arrowButtonStyle, opacity: prevEnabled ? 1 : 0.6 }}
|
||||||
>
|
>
|
||||||
<SvgArrowThinLeft width="15" height="15" style={{ margin: -5 }} />
|
<SvgArrowThinLeft width="15" height="15" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="bare"
|
variant="bare"
|
||||||
@@ -1567,7 +1936,6 @@ function MonthSelector({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
margin: '0 5px',
|
|
||||||
}}
|
}}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onOpenMonthMenu?.(month);
|
onOpenMonthMenu?.(month);
|
||||||
@@ -1588,7 +1956,7 @@ function MonthSelector({
|
|||||||
}}
|
}}
|
||||||
style={{ ...arrowButtonStyle, opacity: nextEnabled ? 1 : 0.6 }}
|
style={{ ...arrowButtonStyle, opacity: nextEnabled ? 1 : 0.6 }}
|
||||||
>
|
>
|
||||||
<SvgArrowThinRight width="15" height="15" style={{ margin: -5 }} />
|
<SvgArrowThinRight width="15" height="15" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ type CategoryAutocompleteModalProps = Extract<
|
|||||||
>['options'];
|
>['options'];
|
||||||
|
|
||||||
export function CategoryAutocompleteModal({
|
export function CategoryAutocompleteModal({
|
||||||
|
title,
|
||||||
month,
|
month,
|
||||||
onSelect,
|
onSelect,
|
||||||
categoryGroups,
|
categoryGroups,
|
||||||
showHiddenCategories,
|
showHiddenCategories,
|
||||||
|
closeOnSelect,
|
||||||
|
clearOnSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: CategoryAutocompleteModalProps) {
|
}: CategoryAutocompleteModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -57,7 +60,7 @@ export function CategoryAutocompleteModal({
|
|||||||
<ModalHeader
|
<ModalHeader
|
||||||
title={
|
title={
|
||||||
<ModalTitle
|
<ModalTitle
|
||||||
title={t('Category')}
|
title={title || t('Category')}
|
||||||
getStyle={() => ({ color: theme.menuAutoCompleteText })}
|
getStyle={() => ({ color: theme.menuAutoCompleteText })}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -88,6 +91,8 @@ export function CategoryAutocompleteModal({
|
|||||||
focused={true}
|
focused={true}
|
||||||
embedded={true}
|
embedded={true}
|
||||||
closeOnBlur={false}
|
closeOnBlur={false}
|
||||||
|
closeOnSelect={closeOnSelect}
|
||||||
|
clearOnSelect={clearOnSelect}
|
||||||
showSplitOption={false}
|
showSplitOption={false}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
{...defaultAutocompleteProps}
|
{...defaultAutocompleteProps}
|
||||||
|
|||||||
@@ -212,10 +212,13 @@ export type Modal =
|
|||||||
| {
|
| {
|
||||||
name: 'category-autocomplete';
|
name: 'category-autocomplete';
|
||||||
options: {
|
options: {
|
||||||
|
title?: string;
|
||||||
categoryGroups?: CategoryGroupEntity[];
|
categoryGroups?: CategoryGroupEntity[];
|
||||||
onSelect: (categoryId: string, categoryName: string) => void;
|
onSelect: (categoryId: string, categoryName: string) => void;
|
||||||
month?: string | undefined;
|
month?: string | undefined;
|
||||||
showHiddenCategories?: boolean;
|
showHiddenCategories?: boolean;
|
||||||
|
closeOnSelect?: boolean;
|
||||||
|
clearOnSelect?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as asyncStorage from '../../platform/server/asyncStorage';
|
|||||||
import { getLocale } from '../../shared/locale';
|
import { getLocale } from '../../shared/locale';
|
||||||
import * as monthUtils from '../../shared/months';
|
import * as monthUtils from '../../shared/months';
|
||||||
import { integerToCurrency, safeNumber } from '../../shared/util';
|
import { integerToCurrency, safeNumber } from '../../shared/util';
|
||||||
|
import { CategoryEntity } from '../../types/models';
|
||||||
import * as db from '../db';
|
import * as db from '../db';
|
||||||
import * as sheet from '../sheet';
|
import * as sheet from '../sheet';
|
||||||
import { batchMessages } from '../sync';
|
import { batchMessages } from '../sync';
|
||||||
@@ -113,7 +114,7 @@ export function setBudget({
|
|||||||
month,
|
month,
|
||||||
amount,
|
amount,
|
||||||
}: {
|
}: {
|
||||||
category: string;
|
category: CategoryEntity['id'];
|
||||||
month: string;
|
month: string;
|
||||||
amount: unknown;
|
amount: unknown;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -408,15 +409,15 @@ export async function coverOverspending({
|
|||||||
from,
|
from,
|
||||||
}: {
|
}: {
|
||||||
month: string;
|
month: string;
|
||||||
to: string;
|
to: CategoryEntity['id'] | 'to-budget';
|
||||||
from: string;
|
from: CategoryEntity['id'] | 'to-budget' | 'overbudgeted';
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const sheetName = monthUtils.sheetForMonth(month);
|
const sheetName = monthUtils.sheetForMonth(month);
|
||||||
const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
|
const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
|
||||||
const leftover = await getSheetValue(sheetName, 'leftover-' + to);
|
const leftover = await getSheetValue(sheetName, 'leftover-' + to);
|
||||||
const leftoverFrom = await getSheetValue(
|
const leftoverFrom = await getSheetValue(
|
||||||
sheetName,
|
sheetName,
|
||||||
from === 'to-be-budgeted' ? 'to-budget' : 'leftover-' + from,
|
from === 'to-budget' ? 'to-budget' : 'leftover-' + from,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (leftover >= 0 || leftoverFrom <= 0) {
|
if (leftover >= 0 || leftoverFrom <= 0) {
|
||||||
@@ -426,7 +427,7 @@ export async function coverOverspending({
|
|||||||
const amountCovered = Math.min(-leftover, leftoverFrom);
|
const amountCovered = Math.min(-leftover, leftoverFrom);
|
||||||
|
|
||||||
// If we are covering it from the to be budgeted amount, ignore this
|
// If we are covering it from the to be budgeted amount, ignore this
|
||||||
if (from !== 'to-be-budgeted') {
|
if (from !== 'to-budget') {
|
||||||
const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
|
const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
|
||||||
await setBudget({
|
await setBudget({
|
||||||
category: from,
|
category: from,
|
||||||
@@ -500,8 +501,8 @@ export async function transferCategory({
|
|||||||
}: {
|
}: {
|
||||||
month: string;
|
month: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
to: string;
|
to: CategoryEntity['id'] | 'to-budget';
|
||||||
from: string;
|
from: CategoryEntity['id'] | 'to-budget';
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const sheetName = monthUtils.sheetForMonth(month);
|
const sheetName = monthUtils.sheetForMonth(month);
|
||||||
const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
|
const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
|
||||||
@@ -511,7 +512,7 @@ export async function transferCategory({
|
|||||||
|
|
||||||
// If we are simply moving it back into available cash to budget,
|
// If we are simply moving it back into available cash to budget,
|
||||||
// don't do anything else
|
// don't do anything else
|
||||||
if (to !== 'to-be-budgeted') {
|
if (to !== 'to-budget') {
|
||||||
const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
|
const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
|
||||||
await setBudget({ category: to, month, amount: toBudgeted + amount });
|
await setBudget({ category: to, month, amount: toBudgeted + amount });
|
||||||
}
|
}
|
||||||
@@ -556,8 +557,8 @@ async function addMovementNotes({
|
|||||||
}: {
|
}: {
|
||||||
month: string;
|
month: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
to: 'to-be-budgeted' | 'overbudgeted' | string;
|
to: CategoryEntity['id'] | 'to-budget' | 'overbudgeted';
|
||||||
from: 'to-be-budgeted' | string;
|
from: CategoryEntity['id'] | 'to-budget';
|
||||||
}) {
|
}) {
|
||||||
const displayAmount = integerToCurrency(amount);
|
const displayAmount = integerToCurrency(amount);
|
||||||
|
|
||||||
@@ -576,16 +577,16 @@ async function addMovementNotes({
|
|||||||
locale,
|
locale,
|
||||||
);
|
);
|
||||||
const categories = await db.getCategories(
|
const categories = await db.getCategories(
|
||||||
[from, to].filter(c => c !== 'to-be-budgeted' && c !== 'overbudgeted'),
|
[from, to].filter(c => c !== 'to-budget' && c !== 'overbudgeted'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fromCategoryName =
|
const fromCategoryName =
|
||||||
from === 'to-be-budgeted'
|
from === 'to-budget'
|
||||||
? 'To Budget'
|
? 'To Budget'
|
||||||
: categories.find(c => c.id === from)?.name;
|
: categories.find(c => c.id === from)?.name;
|
||||||
|
|
||||||
const toCategoryName =
|
const toCategoryName =
|
||||||
to === 'to-be-budgeted'
|
to === 'to-budget'
|
||||||
? 'To Budget'
|
? 'To Budget'
|
||||||
: to === 'overbudgeted'
|
: to === 'overbudgeted'
|
||||||
? 'Overbudgeted'
|
? 'Overbudgeted'
|
||||||
|
|||||||
6
upcoming-release-notes/4643.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Enhancements
|
||||||
|
authors: [joel-jeremy]
|
||||||
|
---
|
||||||
|
|
||||||
|
Add budget table banners to alert users of recommended budget actions e.g. when there is available funds to be budgeted, when user has overbudgeted, when there are overspent categories, when there are uncategorized transactions (reworked).
|
||||||