Skip to content

Commit

Permalink
feat(TransactionsImports): add on hold status (#10953)
Browse files Browse the repository at this point in the history
  • Loading branch information
Betree authored Jan 30, 2025
1 parent 87ccdaf commit 8b3d0a5
Show file tree
Hide file tree
Showing 33 changed files with 449 additions and 187 deletions.
2 changes: 1 addition & 1 deletion components/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const Count = ({ count, selected }: { count?: number; selected?: boolean }) => {
};

type TabsProps = {
tabs: Array<{ id: string; label: React.ReactNode | string; count?: number }>;
tabs: readonly { id: string; label: React.ReactNode | string; count?: number }[];
selectedId?: string;
onChange?: (value: string) => void;
variant?: 'horizontal' | 'vertical';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { gql, useApolloClient, useQuery } from '@apollo/client';
import { partition, size, truncate } from 'lodash';
import { size, truncate } from 'lodash';
import Lottie from 'lottie-react';
import {
Calendar,
Expand All @@ -10,6 +10,7 @@ import {
FileSliders,
Info,
MessageCircle,
PauseCircle,
RefreshCw,
RotateCcw,
Settings,
Expand All @@ -35,7 +36,8 @@ import useQueryFilter from '../../../../lib/hooks/useQueryFilter';
import { i18nTransactionsRowStatus } from '../../../../lib/i18n/transactions-import-row';
import { cn, sortSelectOptions } from '../../../../lib/utils';
import { useTransactionsImportActions } from './lib/actions';
import { TransactionsImportRowFieldsFragment } from './lib/graphql';
import { TransactionsImportRowFieldsFragment, TransactionsImportStatsFragment } from './lib/graphql';
import { getPossibleActionsForSelectedRows } from './lib/table-selection';

import { accountingCategoryFields } from '@/components/expenses/graphql/fragments';

Expand Down Expand Up @@ -140,11 +142,7 @@ const transactionsImportQuery = gql`
size
}
stats {
total
ignored
expenses
orders
processed
...TransactionsImportStats
}
type
csvConfig
Expand All @@ -156,6 +154,7 @@ const transactionsImportQuery = gql`
account {
id
slug
currency
... on AccountWithHost {
host {
...TransactionsImportHostFields
Expand All @@ -179,6 +178,7 @@ const transactionsImportQuery = gql`
}
${TransactionsImportRowFieldsFragment}
${transactionsImportHostFieldsFragment}
${TransactionsImportStatsFragment}
`;

const transactionsImportLasSyncAtPollQuery = gql`
Expand All @@ -194,28 +194,46 @@ const transactionsImportLasSyncAtPollQuery = gql`

const DEFAULT_PAGE_SIZE = 50;

const getViews = intl => [
{
id: 'PENDING',
label: intl.formatMessage({ defaultMessage: 'Pending', id: 'eKEL/g' }),
filter: { status: TransactionsImportRowStatus.PENDING },
},
{
id: 'IGNORED',
label: intl.formatMessage({ defaultMessage: 'No action', id: 'zue9QR' }),
filter: { status: TransactionsImportRowStatus.IGNORED },
},
{
id: 'LINKED',
label: intl.formatMessage({ defaultMessage: 'Imported', id: 'transaction.imported' }),
filter: { status: TransactionsImportRowStatus.LINKED },
},
{
id: 'ALL',
label: intl.formatMessage({ defaultMessage: 'All', id: 'all' }),
filter: {},
},
];
const getViews = intl =>
[
{
id: 'PENDING',
label: intl.formatMessage({ defaultMessage: 'Pending', id: 'eKEL/g' }),
filter: { status: TransactionsImportRowStatus.PENDING },
},
{
id: 'IGNORED',
label: intl.formatMessage({ defaultMessage: 'No action', id: 'zue9QR' }),
filter: { status: TransactionsImportRowStatus.IGNORED },
},
{
id: 'ON_HOLD',
label: intl.formatMessage({ defaultMessage: 'On Hold', id: 'PQoBVd' }),
filter: { status: TransactionsImportRowStatus.ON_HOLD },
},
{
id: 'LINKED',
label: intl.formatMessage({ defaultMessage: 'Imported', id: 'transaction.imported' }),
filter: { status: TransactionsImportRowStatus.LINKED },
},
{
id: 'ALL',
label: intl.formatMessage({ defaultMessage: 'All', id: 'all' }),
filter: {},
},
] as const;

const addCountsToViews = (views, stats) => {
const viewIdToStatsKey = {
PENDING: 'pending',
IGNORED: 'ignored',
ON_HOLD: 'onHold',
LINKED: 'processed',
ALL: 'total',
};

return views.map(view => ({ ...view, count: stats[viewIdToStatsKey[view.id]] }));
};

const rowStatusFilterSchema = z.nativeEnum(TransactionsImportRowStatus).optional();

Expand Down Expand Up @@ -328,7 +346,7 @@ export const TransactionsImport = ({ accountSlug, importId }) => {
},
});

const { getActions, setRowsDismissed } = useTransactionsImportActions({
const { getActions, setRowsStatus } = useTransactionsImportActions({
transactionsImport: importData,
host: importData?.account?.['host'],
});
Expand Down Expand Up @@ -374,7 +392,7 @@ export const TransactionsImport = ({ accountSlug, importId }) => {
<StepMapCSVColumns
importId={importId}
file={csvFile}
currency={importData.account['currency']}
currency={importData.account.currency}
onSuccess={refetch}
/>
) : null}
Expand Down Expand Up @@ -521,7 +539,11 @@ export const TransactionsImport = ({ accountSlug, importId }) => {

<div className="px-2">
{/** Tabs & filters */}
<Filterbar className="mb-4" {...queryFilter} />
<Filterbar
className="mb-4"
{...queryFilter}
views={addCountsToViews(queryFilter.views, importData?.stats)}
/>

{/** Select all message */}
{size(selection.rows) === importRows.length && hasPagination && (
Expand Down Expand Up @@ -570,7 +592,9 @@ export const TransactionsImport = ({ accountSlug, importId }) => {
<DataTable<TransactionsImportQuery['transactionsImport']['rows']['nodes'][number], unknown>
loading={loading || !importData.lastSyncAt}
getRowClassName={row =>
row.original.isDismissed ? '[&>td:nth-child(n+2):nth-last-child(n+3)]:opacity-30' : ''
row.original.status === TransactionsImportRowStatus.IGNORED
? '[&>td:nth-child(n+2):nth-last-child(n+3)]:opacity-30'
: ''
}
enableMultiRowSelection
rowSelection={selection.rows}
Expand Down Expand Up @@ -697,64 +721,97 @@ export const TransactionsImport = ({ accountSlug, importId }) => {
id: 'actions',
...actionsColumn,
header: ({ table }) => {
const selectedRows = table.getSelectedRowModel().rows;
const unprocessedRows = selectedRows.filter(
({ original }) => !original.expense && !original.order,
);
const [ignoredRows, nonIgnoredRows] = partition(
unprocessedRows,
row => row.original.isDismissed,
);
const selectedRows = table.getSelectedRowModel().rows.map(row => row.original);
const includeAllPages = selection.includeAllPages;
const rowsActions = getPossibleActionsForSelectedRows(selectedRows);
return (
<div className="flex min-w-36 justify-end">
{ignoredRows.length && !nonIgnoredRows.length ? (
// If all non-processed rows are dismissed, show restore button
<Button
variant="outline"
size="xs"
className="whitespace-nowrap text-xs"
onClick={async () => {
const ignoredIds = ignoredRows.map(row => row.original.id);
await setRowsDismissed(ignoredIds, false, {
includeAllRows: selection.includeAllPages,
});
table.setRowSelection({});
}}
>
<SquareSlashIcon size={12} />
{selection.includeAllPages ? (
<FormattedMessage defaultMessage="Restore all rows" id="8uECrb" />
) : (
<FormattedMessage
defaultMessage="Restore {selectedCount}"
id="restore"
values={{ selectedCount: ignoredRows.length }}
/>
{includeAllPages ||
rowsActions.canIgnore.length ||
rowsActions.canRestore.length ||
rowsActions.canPutOnHold.length ? (
<div className="flex gap-1">
{(includeAllPages || rowsActions.canRestore.length > 0) && (
<Button
variant="outline"
size="xs"
className="whitespace-nowrap text-xs"
onClick={async () => {
await setRowsStatus(
rowsActions.canRestore,
TransactionsImportRowStatus.PENDING,
{ includeAllPages },
);
table.setRowSelection({});
}}
>
<SquareSlashIcon size={12} />
{includeAllPages ? (
<FormattedMessage defaultMessage="Restore all rows" id="8uECrb" />
) : (
<FormattedMessage
defaultMessage="Restore {selectedCount}"
id="restore"
values={{ selectedCount: rowsActions.canRestore.length }}
/>
)}
</Button>
)}
</Button>
) : nonIgnoredRows.length || selection.includeAllPages ? (
// Otherwise, show ignore button
<Button
variant="outline"
size="xs"
className="whitespace-nowrap text-xs"
onClick={() => {
const ignoredIds = nonIgnoredRows.map(row => row.original.id);
setRowsDismissed(ignoredIds, true, { includeAllRows: selection.includeAllPages });
table.setRowSelection({});
}}
>
<SquareSlashIcon size={12} />
{selection.includeAllPages ? (
<FormattedMessage defaultMessage="No action (all)" id="UFhJFs" />
) : (
<FormattedMessage
defaultMessage="No action ({selectedCount})"
id="B2eNk+"
values={{ selectedCount: nonIgnoredRows.length }}
/>

{(includeAllPages || rowsActions.canIgnore.length > 0) && (
<Button
variant="outline"
size="xs"
className="whitespace-nowrap text-xs"
onClick={async () => {
await setRowsStatus(
rowsActions.canIgnore,
TransactionsImportRowStatus.IGNORED,
{ includeAllPages },
);
table.setRowSelection({});
}}
>
<SquareSlashIcon size={12} />
{includeAllPages ? (
<FormattedMessage defaultMessage="No action (all)" id="UFhJFs" />
) : (
<FormattedMessage
defaultMessage="No action ({selectedCount})"
id="B2eNk+"
values={{ selectedCount: rowsActions.canIgnore.length }}
/>
)}
</Button>
)}
</Button>

{(includeAllPages || rowsActions.canPutOnHold.length > 0) && (
<Button
variant="outline"
size="xs"
className="whitespace-nowrap text-xs"
onClick={async () => {
await setRowsStatus(
rowsActions.canPutOnHold,
TransactionsImportRowStatus.ON_HOLD,
{ includeAllPages },
);
table.setRowSelection({});
}}
>
<PauseCircle size={12} />
{includeAllPages ? (
<FormattedMessage defaultMessage="Put all on hold" id="putAllOnHold" />
) : (
<FormattedMessage
defaultMessage="Put on hold ({selectedCount})"
id="putOnHoldCount"
values={{ selectedCount: rowsActions.canPutOnHold.length }}
/>
)}
</Button>
)}
</div>
) : (
<div>
<FormattedMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ import React from 'react';
import { useIntl } from 'react-intl';

import type { TransactionsImportRow } from '../../../../lib/graphql/types/v2/schema';
import { TransactionsImportRowStatus } from '../../../../lib/graphql/types/v2/schema';
import { i18nTransactionsRowStatus } from '../../../../lib/i18n/transactions-import-row';

import { Badge } from '../../../ui/Badge';

export const TransactionsImportRowStatusBadge = ({
row,
}: {
row: Pick<TransactionsImportRow, 'isDismissed'> & {
row: Pick<TransactionsImportRow, 'status'> & {
expense?: Pick<TransactionsImportRow['expense'], 'id'> | null | undefined;
order?: Pick<TransactionsImportRow['order'], 'id'> | null | undefined;
};
}) => {
const intl = useIntl();
if (row.isDismissed) {
if (row.status === TransactionsImportRowStatus.IGNORED) {
return (
<Badge className="whitespace-nowrap" size="sm">
{i18nTransactionsRowStatus(intl, 'IGNORED')}
Expand All @@ -27,6 +28,12 @@ export const TransactionsImportRowStatusBadge = ({
{i18nTransactionsRowStatus(intl, 'LINKED')}
</Badge>
);
} else if (row.status === TransactionsImportRowStatus.ON_HOLD) {
return (
<Badge type="warning" size="sm">
{i18nTransactionsRowStatus(intl, 'ON_HOLD')}
</Badge>
);
} else {
return (
<Badge type="info" size="sm">
Expand Down
Loading

0 comments on commit 8b3d0a5

Please sign in to comment.