This commit is contained in:
Joel Jeremy Marquez
2025-10-27 11:19:09 -07:00
parent f378d75727
commit 1cabe8ab6c
9 changed files with 285 additions and 52 deletions

View File

@@ -46,6 +46,8 @@ import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
@@ -144,32 +146,28 @@ export type ColumnDefinition = {
};
type BudgetCategoriesProps = {
categoryGroups: CategoryGroupEntity[];
onBudgetAction: (month: string, type: string, args: unknown) => void;
onShowActivity: (id: CategoryEntity['id'], month?: string) => void;
onSaveCategory?: (category: CategoryEntity) => void;
onSaveGroup?: (group: CategoryGroupEntity) => void;
onDeleteCategory?: (id: CategoryEntity['id']) => void;
onDeleteGroup?: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup?: (categoryIds: CategoryEntity['id'][]) => void;
onApplyBudgetTemplatesInGroup: (categoryIds: CategoryEntity['id'][]) => void;
onToggleHiddenCategories: () => void;
onCollapseAllCategories: () => void;
onExpandAllCategories: () => void;
};
export function BudgetCategories({
categoryGroups,
onBudgetAction,
onShowActivity,
onSaveCategory,
onSaveGroup,
onDeleteCategory,
onDeleteGroup,
onApplyBudgetTemplatesInGroup,
onToggleHiddenCategories,
onCollapseAllCategories,
onExpandAllCategories,
}: BudgetCategoriesProps) {
const { grouped: categoryGroups } = useCategories();
const {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
} = useCategoryMutations();
const { t } = useTranslation();
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
useLocalPref('budget.collapsed');
@@ -197,13 +195,13 @@ export function BudgetCategories({
return [];
}
const groupCategories = expenseGroup.categories ?? [];
const expenseGroupCategories = collapsedGroupIds.includes(
expenseGroup.id,
)
? []
: expenseGroup.categories.filter(
cat => showHiddenCategories || !cat.hidden,
);
: groupCategories.filter(cat => showHiddenCategories || !cat.hidden);
const expenseGroupItems: Item[] = [
{
@@ -261,11 +259,11 @@ export function BudgetCategories({
});
}
const groupCategories = incomeGroup.categories ?? [];
const incomeGroupCategories = collapsedGroupIds.includes(incomeGroup.id)
? []
: incomeGroup.categories.filter(
cat => showHiddenCategories || !cat.hidden,
);
: groupCategories.filter(cat => showHiddenCategories || !cat.hidden);
incomeGroupItems.push(
...incomeGroupCategories.map(
@@ -510,10 +508,11 @@ export function BudgetCategories({
onToggleVisibilty={group => {
onSaveGroup({
...group,
hidden: !item.value.hidden,
hidden: !!item.value.hidden ? false : true,
});
}}
onApplyBudgetTemplatesInGroup={group =>
group.categories &&
onApplyBudgetTemplatesInGroup(
group.categories
.filter(cat => !cat.hidden)
@@ -578,6 +577,7 @@ export function BudgetCategories({
});
}}
onApplyBudgetTemplatesInGroup={group =>
group.categories &&
onApplyBudgetTemplatesInGroup(
group.categories
.filter(cat => !cat.hidden)
@@ -614,14 +614,15 @@ export function BudgetCategories({
id="new-category-row"
columns={columns}
onUpdate={name => {
if (name) {
const group = categoryGroups.find(
g => g.id === groupOfNewCategory,
);
if (name && group) {
onSaveCategoryAndClose({
id: 'new',
name,
group: groupOfNewCategory,
is_income:
groupOfNewCategory ===
categoryGroups.find(g => g.is_income).id,
group: group.id,
is_income: group.is_income,
});
} else {
onHideNewCategoryInput();

View File

@@ -22,6 +22,8 @@ import {
separateGroups,
} from './util';
import { type BudgetComponents } from '.';
import { type DropPosition } from '@desktop-client/components/sort';
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -308,13 +310,7 @@ export function BudgetTable(props: BudgetTableProps) {
{budgetTableV2Enabled && (
<View style={{ overflowY: 'auto' }}>
<BudgetCategoriesV2
categoryGroups={categoryGroups}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
onToggleHiddenCategories={onToggleHiddenCategories}
onExpandAllCategories={onExpandAllCategories}

View File

@@ -86,11 +86,6 @@ export function CategoryBalanceCell({
typeof categoryBudgetedBinding
>(categoryBudgetedBinding);
const balanceValue = useSheetValue<
typeof bindingBudgetType,
typeof categoryBalanceBinding
>(categoryBalanceBinding);
const goalValue = useSheetValue<
typeof bindingBudgetType,
typeof categoryGoalBinding
@@ -103,10 +98,6 @@ export function CategoryBalanceCell({
const [isBalanceMenuOpen, setIsBalanceMenuOpen] = useState(false);
const [activeBalanceMenu, setActiveBalanceMenu] = useState<
'balance' | 'transfer' | 'cover' | null
>(null);
const { pressProps } = usePress({
onPress: () => setIsBalanceMenuOpen(true),
});

View File

@@ -229,8 +229,10 @@ function BudgetedInput({
onUpdate={(newValue, e) => {
onUpdate?.(newValue, e);
const integerAmount = currencyToInteger(newValue);
onUpdateAmount?.(integerAmount);
setCurrentFormattedAmount(format(integerAmount, 'financial'));
if (integerAmount) {
onUpdateAmount?.(integerAmount);
setCurrentFormattedAmount(format(integerAmount, 'financial'));
}
}}
{...props}
/>

View File

@@ -143,10 +143,14 @@ export function CategoryGroupNameCell({
items={[
{ name: 'add-category', text: t('Add category') },
{ name: 'rename', text: t('Rename') },
!categoryGroup.is_income && {
name: 'toggle-visibility',
text: categoryGroup.hidden ? 'Show' : 'Hide',
},
...(!categoryGroup.is_income
? [
{
name: 'toggle-visibility',
text: categoryGroup.hidden ? 'Show' : 'Hide',
},
]
: []),
// onDelete && { name: 'delete', text: t('Delete') },
{ name: 'delete', text: t('Delete') },
...(isGoalTemplatesEnabled

View File

@@ -112,10 +112,14 @@ export function CategoryNameCell({
}}
items={[
{ name: 'rename', text: t('Rename') },
!categoryGroup?.hidden && {
name: 'toggle-visibility',
text: category.hidden ? t('Show') : t('Hide'),
},
...(!categoryGroup?.hidden
? [
{
name: 'toggle-visibility',
text: category.hidden ? t('Show') : t('Hide'),
},
]
: []),
{ name: 'delete', text: t('Delete') },
]}
/>

View File

@@ -543,6 +543,13 @@ export function IncomeGroupMonth({ month }: IncomeGroupMonthProps) {
);
}
type IncomeCategoryMonthProps = {
category: CategoryEntity;
isLast?: boolean;
month: string;
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
};
export function IncomeCategoryMonth({
category,
isLast,

View File

@@ -23,7 +23,7 @@ import {
getCategories,
} from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
@@ -138,7 +138,7 @@ export function Budget() {
onShowActivity,
onReorderCategory,
onReorderGroup,
} = useCategoryActions();
} = useCategoryMutations();
if (!initialized || !categoryGroups) {
return null;

View File

@@ -0,0 +1,228 @@
import { useTranslation } from 'react-i18next';
import { send } from 'loot-core/platform/client/fetch';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { useCategories } from './useCategories';
import { useNavigate } from './useNavigate';
import {
createCategory,
createCategoryGroup,
deleteCategory,
deleteCategoryGroup,
moveCategory,
moveCategoryGroup,
updateCategory,
updateCategoryGroup,
} from '@desktop-client/budget/budgetSlice';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function useCategoryMutations() {
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const { grouped: categoryGroups } = useCategories();
const categoryNameAlreadyExistsNotification = (
name: CategoryEntity['name'],
) => {
dispatch(
addNotification({
notification: {
type: 'error',
message: t(
'Category “{{name}}” already exists in group (it may be hidden)',
{ name },
),
},
}),
);
};
const onSaveCategory = async (category: CategoryEntity) => {
const { grouped: categoryGroups = [] } = await send('get-categories');
const group = categoryGroups.find(g => g.id === category.group);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === category.name.toUpperCase())
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
.length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(category.name);
return;
}
if (category.id === 'new') {
dispatch(
createCategory({
name: category.name,
groupId: category.group,
isIncome: !!category.is_income,
isHidden: !!category.hidden,
}),
);
} else {
dispatch(updateCategory({ category }));
}
};
const onDeleteCategory = async (id: CategoryEntity['id']) => {
const mustTransfer = await send('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: id,
onDelete: transferCategory => {
if (id !== transferCategory) {
dispatch(
deleteCategory({ id, transferId: transferCategory }),
);
}
},
},
},
}),
);
} else {
dispatch(deleteCategory({ id }));
}
};
const onSaveGroup = (group: CategoryGroupEntity) => {
if (group.id === 'new') {
dispatch(createCategoryGroup({ name: group.name }));
} else {
dispatch(updateCategoryGroup({ group }));
}
};
const onDeleteGroup = async (id: CategoryGroupEntity['id']) => {
const group = categoryGroups.find(g => g.id === id);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
let mustTransfer = false;
for (const category of groupCategories) {
if (await send('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: id,
onDelete: transferCategory => {
dispatch(
deleteCategoryGroup({ id, transferId: transferCategory }),
);
},
},
},
}),
);
} else {
dispatch(deleteCategoryGroup({ id }));
}
};
const onShowActivity = (categoryId: CategoryEntity['id'], month: string) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
op: 'is',
value: month,
options: { month: true },
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
categoryId,
},
});
};
const onReorderCategory = async (sortInfo: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => {
const { grouped: categoryGroups = [], list: categories = [] } =
await send('get-categories');
const moveCandidate = categories.find(c => c.id === sortInfo.id);
const group = categoryGroups.find(g => g.id === sortInfo.groupId);
if (!moveCandidate || !group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === moveCandidate.name.toUpperCase())
.filter(c => c.id !== moveCandidate.id).length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(moveCandidate.name);
return;
}
dispatch(
moveCategory({
id: moveCandidate.id,
groupId: group.id,
targetId: sortInfo.targetId,
}),
);
};
const onReorderGroup = async (sortInfo: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
}) => {
dispatch(
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
);
};
return {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
onReorderCategory,
onReorderGroup,
};
}