mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Compare commits
8 Commits
Transactio
...
mobile/lin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19285ed594 | ||
|
|
62fa79effc | ||
|
|
89cea7ecc9 | ||
|
|
465a3a3fa5 | ||
|
|
d1d3e360f5 | ||
|
|
bfa8115452 | ||
|
|
7ebf687a96 | ||
|
|
fb2ec46981 |
@@ -2,6 +2,8 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
Cell,
|
||||
} from '@desktop-client/components/table';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { closeModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
@@ -107,8 +110,12 @@ export function SelectLinkedAccountsModal({
|
||||
}, [externalAccounts, syncSource, requisitionId]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const dispatch = useDispatch();
|
||||
const localAccounts = useAccounts().filter(a => a.closed === 0);
|
||||
const [draftLinkAccounts] = useState<Map<string, 'linking' | 'unlinking'>>(
|
||||
new Map(),
|
||||
);
|
||||
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
|
||||
() => {
|
||||
return Object.fromEntries(
|
||||
@@ -216,133 +223,216 @@ export function SelectLinkedAccountsModal({
|
||||
|
||||
if (localAccountId) {
|
||||
updatedAccounts[externalAccount.account_id] = localAccountId;
|
||||
draftLinkAccounts.set(externalAccount.account_id, 'linking');
|
||||
} else {
|
||||
delete updatedAccounts[externalAccount.account_id];
|
||||
draftLinkAccounts.set(externalAccount.account_id, 'unlinking');
|
||||
}
|
||||
|
||||
return updatedAccounts;
|
||||
});
|
||||
}
|
||||
|
||||
const getChosenAccount = (accountId: string) => {
|
||||
const chosenId = chosenAccounts[accountId];
|
||||
if (!chosenId) return undefined;
|
||||
|
||||
if (chosenId === addOnBudgetAccountOption.id) {
|
||||
return addOnBudgetAccountOption;
|
||||
}
|
||||
if (chosenId === addOffBudgetAccountOption.id) {
|
||||
return addOffBudgetAccountOption;
|
||||
}
|
||||
|
||||
return localAccounts.find(acc => acc.id === chosenId);
|
||||
};
|
||||
|
||||
const label = useMemo(() => {
|
||||
const s = new Set(draftLinkAccounts.values());
|
||||
if (s.has('linking') && s.has('unlinking')) {
|
||||
return t('Link and unlink accounts');
|
||||
} else if (s.has('linking')) {
|
||||
return t('Link accounts');
|
||||
} else if (s.has('unlinking')) {
|
||||
return t('Unlink accounts');
|
||||
}
|
||||
|
||||
return t('Link or unlink accounts');
|
||||
}, [draftLinkAccounts, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="select-linked-accounts"
|
||||
containerProps={{ style: { width: 1000 } }}
|
||||
containerProps={{
|
||||
style: isNarrowWidth
|
||||
? {
|
||||
width: '100vw',
|
||||
maxWidth: '100vw',
|
||||
height: '100vh',
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
: { width: 1000 },
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<View
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||
>
|
||||
<ModalHeader
|
||||
title={t('Link Accounts')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<Text style={{ marginBottom: 10 }}>
|
||||
<Trans>
|
||||
We found the following accounts. Select which ones you want to
|
||||
add:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 'unset',
|
||||
height: 300,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
padding: isNarrowWidth ? '0 16px' : '0 20px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<Cell name={t('Institution to Sync')} width={175} />
|
||||
<Cell name={t('Bank Account To Sync')} width={175} />
|
||||
<Cell name={t('Balance')} width={80} />
|
||||
<Cell name={t('Account in Actual')} width="flex" />
|
||||
<Cell name={t('Actions')} width={150} />
|
||||
</TableHeader>
|
||||
|
||||
<Table<
|
||||
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
|
||||
id: string;
|
||||
}
|
||||
>
|
||||
items={propsWithSortedExternalAccounts.externalAccounts.map(
|
||||
account => ({
|
||||
...account,
|
||||
id: account.account_id,
|
||||
}),
|
||||
)}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
getItemKey={String}
|
||||
renderItem={({ item }) => (
|
||||
<View key={item.id}>
|
||||
<TableRow
|
||||
externalAccount={item}
|
||||
chosenAccount={
|
||||
chosenAccounts[item.account_id] ===
|
||||
addOnBudgetAccountOption.id
|
||||
? addOnBudgetAccountOption
|
||||
: chosenAccounts[item.account_id] ===
|
||||
addOffBudgetAccountOption.id
|
||||
? addOffBudgetAccountOption
|
||||
: localAccounts.find(
|
||||
acc => chosenAccounts[item.account_id] === acc.id,
|
||||
)
|
||||
}
|
||||
unlinkedAccounts={unlinkedAccounts}
|
||||
onSetLinkedAccount={onSetLinkedAccount}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
<Text style={{ marginBottom: 20 }}>
|
||||
<Trans>
|
||||
We found the following accounts. Select which ones you want to
|
||||
add:
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isNarrowWidth ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{propsWithSortedExternalAccounts.externalAccounts.map(account => (
|
||||
<AccountCard
|
||||
key={account.account_id}
|
||||
externalAccount={account}
|
||||
chosenAccount={getChosenAccount(account.account_id)}
|
||||
unlinkedAccounts={unlinkedAccounts}
|
||||
onSetLinkedAccount={onSetLinkedAccount}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 'unset',
|
||||
height: 300,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<Cell value={t('Institution to Sync')} width={175} />
|
||||
<Cell value={t('Bank Account To Sync')} width={175} />
|
||||
<Cell value={t('Balance')} width={80} />
|
||||
<Cell value={t('Account in Actual')} width="flex" />
|
||||
<Cell value={t('Actions')} width={150} />
|
||||
</TableHeader>
|
||||
|
||||
<Table<
|
||||
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
|
||||
id: string;
|
||||
}
|
||||
>
|
||||
items={propsWithSortedExternalAccounts.externalAccounts.map(
|
||||
account => ({
|
||||
...account,
|
||||
id: account.account_id,
|
||||
}),
|
||||
)}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
renderItem={({ item }) => (
|
||||
<View key={item.id}>
|
||||
<TableRow
|
||||
externalAccount={item}
|
||||
chosenAccount={getChosenAccount(item.account_id)}
|
||||
unlinkedAccounts={unlinkedAccounts}
|
||||
onSetLinkedAccount={onSetLinkedAccount}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 10,
|
||||
justifyContent: isNarrowWidth ? 'center' : 'flex-end',
|
||||
...(isNarrowWidth
|
||||
? {
|
||||
padding: '16px',
|
||||
flexShrink: 0,
|
||||
borderTop: `1px solid ${theme.tableBorder}`,
|
||||
}
|
||||
: { marginTop: 10 }),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={onNext}
|
||||
isDisabled={!Object.keys(chosenAccounts).length}
|
||||
isDisabled={draftLinkAccounts.size === 0}
|
||||
style={
|
||||
isNarrowWidth
|
||||
? {
|
||||
width: '100%',
|
||||
height: '44px',
|
||||
fontSize: '1em',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans>Link accounts</Trans>
|
||||
{label}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function getInstitutionName(
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
) {
|
||||
if (typeof externalAccount?.institution === 'string') {
|
||||
return externalAccount?.institution ?? '';
|
||||
} else if (typeof externalAccount.institution?.name === 'string') {
|
||||
return externalAccount?.institution?.name ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
type ExternalAccount =
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount;
|
||||
|
||||
type TableRowProps = {
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount;
|
||||
type SharedAccountRowProps = {
|
||||
externalAccount: ExternalAccount;
|
||||
chosenAccount: { id: string; name: string } | undefined;
|
||||
unlinkedAccounts: AccountEntity[];
|
||||
onSetLinkedAccount: (
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
externalAccount: ExternalAccount,
|
||||
localAccountId: string | null | undefined,
|
||||
) => void;
|
||||
};
|
||||
|
||||
function getAvailableAccountOptions(
|
||||
unlinkedAccounts: AccountEntity[],
|
||||
chosenAccount: { id: string; name: string } | undefined,
|
||||
addOnBudgetAccountOption: { id: string; name: string },
|
||||
addOffBudgetAccountOption: { id: string; name: string },
|
||||
): AutocompleteItem[] {
|
||||
const options: AutocompleteItem[] = [...unlinkedAccounts];
|
||||
if (
|
||||
chosenAccount &&
|
||||
chosenAccount.id !== addOnBudgetAccountOption.id &&
|
||||
chosenAccount.id !== addOffBudgetAccountOption.id
|
||||
) {
|
||||
options.push(chosenAccount);
|
||||
}
|
||||
options.push(addOnBudgetAccountOption, addOffBudgetAccountOption);
|
||||
return options;
|
||||
}
|
||||
|
||||
type TableRowProps = SharedAccountRowProps;
|
||||
|
||||
function TableRow({
|
||||
externalAccount,
|
||||
chosenAccount,
|
||||
@@ -352,12 +442,12 @@ function TableRow({
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
useAddBudgetAccountOptions();
|
||||
const format = useFormat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts];
|
||||
if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) {
|
||||
availableAccountOptions.push(chosenAccount);
|
||||
}
|
||||
availableAccountOptions.push(
|
||||
const availableAccountOptions = getAvailableAccountOptions(
|
||||
unlinkedAccounts,
|
||||
chosenAccount,
|
||||
addOnBudgetAccountOption,
|
||||
addOffBudgetAccountOption,
|
||||
);
|
||||
@@ -391,7 +481,11 @@ function TableRow({
|
||||
</Tooltip>
|
||||
</Field>
|
||||
<Field width={80}>
|
||||
<PrivacyFilter>{externalAccount.balance}</PrivacyFilter>
|
||||
<PrivacyFilter>
|
||||
{!isNaN(Number(externalAccount.balance))
|
||||
? format(externalAccount.balance.toString(), 'financial')
|
||||
: t('Unknown')}
|
||||
</PrivacyFilter>
|
||||
</Field>
|
||||
<Field
|
||||
width="flex"
|
||||
@@ -441,3 +535,161 @@ function TableRow({
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function getInstitutionName(
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
) {
|
||||
if (typeof externalAccount?.institution === 'string') {
|
||||
return externalAccount?.institution ?? '';
|
||||
} else if (typeof externalAccount.institution?.name === 'string') {
|
||||
return externalAccount?.institution?.name ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
type AccountCardProps = SharedAccountRowProps;
|
||||
|
||||
function AccountCard({
|
||||
externalAccount,
|
||||
chosenAccount,
|
||||
unlinkedAccounts,
|
||||
onSetLinkedAccount,
|
||||
}: AccountCardProps) {
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
useAddBudgetAccountOptions();
|
||||
const format = useFormat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableAccountOptions = getAvailableAccountOptions(
|
||||
unlinkedAccounts,
|
||||
chosenAccount,
|
||||
addOnBudgetAccountOption,
|
||||
addOffBudgetAccountOption,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={2}
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
minHeight: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '1.1em',
|
||||
color: theme.pageText,
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{externalAccount.name}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
fontSize: '0.9em',
|
||||
color: theme.pageTextSubdued,
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{getInstitutionName(externalAccount)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
fontSize: '0.9em',
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
<Trans>Balance:</Trans>{' '}
|
||||
<PrivacyFilter>
|
||||
{externalAccount.balance != null
|
||||
? format(externalAccount.balance.toString(), 'financial')
|
||||
: t('Unknown')}
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
style={{
|
||||
fontSize: '0.9em',
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
<Trans>Linked to:</Trans>
|
||||
</Text>
|
||||
{chosenAccount ? (
|
||||
<Text style={{ color: theme.noticeTextLight, fontWeight: 500 }}>
|
||||
{chosenAccount.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ color: theme.pageTextSubdued }}>
|
||||
<Trans>Not linked</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{focusedField === 'account' && (
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<Autocomplete
|
||||
focused
|
||||
strict
|
||||
highlightFirst
|
||||
suggestions={availableAccountOptions}
|
||||
onSelect={value => {
|
||||
onSetLinkedAccount(externalAccount, value);
|
||||
setFocusedField(null);
|
||||
}}
|
||||
inputProps={{
|
||||
onBlur: () => setFocusedField(null),
|
||||
placeholder: t('Select account...'),
|
||||
}}
|
||||
value={chosenAccount?.id}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{chosenAccount ? (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onSetLinkedAccount(externalAccount, null);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.9em',
|
||||
}}
|
||||
>
|
||||
<Trans>Remove bank sync</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
setFocusedField('account');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.9em',
|
||||
}}
|
||||
>
|
||||
<Trans>Link account</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/5984.md
Normal file
6
upcoming-release-notes/5984.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Make bank sync accout linking modal mobile responsive
|
||||
Reference in New Issue
Block a user