Compare commits

...

8 Commits

Author SHA1 Message Date
lelemm
19285ed594 Fix for unlinking account only 2025-10-24 09:09:05 -03:00
Matt Fiddaman
62fa79effc cast to string 2025-10-23 00:09:39 +01:00
Matt Fiddaman
89cea7ecc9 coderabbit 2025-10-22 22:52:16 +01:00
Matt Fiddaman
465a3a3fa5 fix feedback 2025-10-22 22:42:13 +01:00
Matt Fiddaman
d1d3e360f5 coderabbit 2025-10-22 22:41:44 +01:00
Matt Fiddaman
bfa8115452 note 2025-10-22 22:41:44 +01:00
Matt Fiddaman
7ebf687a96 fix table headers 2025-10-22 22:41:44 +01:00
Matt Fiddaman
fb2ec46981 make SelectLinkedAccountModal responsive 2025-10-22 22:41:44 +01:00
2 changed files with 344 additions and 86 deletions

View File

@@ -2,6 +2,8 @@ import React, { useMemo, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button'; 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 { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip'; import { Tooltip } from '@actual-app/components/tooltip';
@@ -38,6 +40,7 @@ import {
Cell, Cell,
} from '@desktop-client/components/table'; } from '@desktop-client/components/table';
import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { closeModal } from '@desktop-client/modals/modalsSlice'; import { closeModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux'; import { useDispatch } from '@desktop-client/redux';
@@ -107,8 +110,12 @@ export function SelectLinkedAccountsModal({
}, [externalAccounts, syncSource, requisitionId]); }, [externalAccounts, syncSource, requisitionId]);
const { t } = useTranslation(); const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const dispatch = useDispatch(); const dispatch = useDispatch();
const localAccounts = useAccounts().filter(a => a.closed === 0); const localAccounts = useAccounts().filter(a => a.closed === 0);
const [draftLinkAccounts] = useState<Map<string, 'linking' | 'unlinking'>>(
new Map(),
);
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>( const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
() => { () => {
return Object.fromEntries( return Object.fromEntries(
@@ -216,133 +223,216 @@ export function SelectLinkedAccountsModal({
if (localAccountId) { if (localAccountId) {
updatedAccounts[externalAccount.account_id] = localAccountId; updatedAccounts[externalAccount.account_id] = localAccountId;
draftLinkAccounts.set(externalAccount.account_id, 'linking');
} else { } else {
delete updatedAccounts[externalAccount.account_id]; delete updatedAccounts[externalAccount.account_id];
draftLinkAccounts.set(externalAccount.account_id, 'unlinking');
} }
return updatedAccounts; 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 ( return (
<Modal <Modal
name="select-linked-accounts" 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 } }) => ( {({ state: { close } }) => (
<> <View
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<ModalHeader <ModalHeader
title={t('Link Accounts')} title={t('Link Accounts')}
rightContent={<ModalCloseButton onPress={close} />} rightContent={<ModalCloseButton onPress={close} />}
/> />
<Text style={{ marginBottom: 10 }}>
<Trans>
We found the following accounts. Select which ones you want to
add:
</Trans>
</Text>
<View <View
style={{ style={{
flex: 'unset', padding: isNarrowWidth ? '0 16px' : '0 20px',
height: 300, flexShrink: 0,
border: '1px solid ' + theme.tableBorder,
}} }}
> >
<TableHeader> <Text style={{ marginBottom: 20 }}>
<Cell name={t('Institution to Sync')} width={175} /> <Trans>
<Cell name={t('Bank Account To Sync')} width={175} /> We found the following accounts. Select which ones you want to
<Cell name={t('Balance')} width={80} /> add:
<Cell name={t('Account in Actual')} width="flex" /> </Trans>
<Cell name={t('Actions')} width={150} /> </Text>
</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>
)}
/>
</View> </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 <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'flex-end', justifyContent: isNarrowWidth ? 'center' : 'flex-end',
marginTop: 10, ...(isNarrowWidth
? {
padding: '16px',
flexShrink: 0,
borderTop: `1px solid ${theme.tableBorder}`,
}
: { marginTop: 10 }),
}} }}
> >
<Button <Button
variant="primary" variant="primary"
onPress={onNext} 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> </Button>
</View> </View>
</> </View>
)} )}
</Modal> </Modal>
); );
} }
function getInstitutionName( type ExternalAccount =
externalAccount: | SyncServerGoCardlessAccount
| SyncServerGoCardlessAccount | SyncServerSimpleFinAccount
| SyncServerSimpleFinAccount | SyncServerPluggyAiAccount;
| SyncServerPluggyAiAccount,
) {
if (typeof externalAccount?.institution === 'string') {
return externalAccount?.institution ?? '';
} else if (typeof externalAccount.institution?.name === 'string') {
return externalAccount?.institution?.name ?? '';
}
return '';
}
type TableRowProps = { type SharedAccountRowProps = {
externalAccount: externalAccount: ExternalAccount;
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount;
chosenAccount: { id: string; name: string } | undefined; chosenAccount: { id: string; name: string } | undefined;
unlinkedAccounts: AccountEntity[]; unlinkedAccounts: AccountEntity[];
onSetLinkedAccount: ( onSetLinkedAccount: (
externalAccount: externalAccount: ExternalAccount,
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
localAccountId: string | null | undefined, localAccountId: string | null | undefined,
) => void; ) => 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({ function TableRow({
externalAccount, externalAccount,
chosenAccount, chosenAccount,
@@ -352,12 +442,12 @@ function TableRow({
const [focusedField, setFocusedField] = useState<string | null>(null); const [focusedField, setFocusedField] = useState<string | null>(null);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } = const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions(); useAddBudgetAccountOptions();
const format = useFormat();
const { t } = useTranslation();
const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts]; const availableAccountOptions = getAvailableAccountOptions(
if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) { unlinkedAccounts,
availableAccountOptions.push(chosenAccount); chosenAccount,
}
availableAccountOptions.push(
addOnBudgetAccountOption, addOnBudgetAccountOption,
addOffBudgetAccountOption, addOffBudgetAccountOption,
); );
@@ -391,7 +481,11 @@ function TableRow({
</Tooltip> </Tooltip>
</Field> </Field>
<Field width={80}> <Field width={80}>
<PrivacyFilter>{externalAccount.balance}</PrivacyFilter> <PrivacyFilter>
{!isNaN(Number(externalAccount.balance))
? format(externalAccount.balance.toString(), 'financial')
: t('Unknown')}
</PrivacyFilter>
</Field> </Field>
<Field <Field
width="flex" width="flex"
@@ -441,3 +535,161 @@ function TableRow({
</Row> </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>
);
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [matt-fidd]
---
Make bank sync accout linking modal mobile responsive