mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
Connect automations UI to backend (#5533)
* Connect automations UI to backend * Fix integer -> amount * Add release notes * Fix length check * No layout shift * Coderabbit: decimal places
This commit is contained in:
committed by
GitHub
parent
89e5676cfb
commit
dc2ab4843f
@@ -102,7 +102,9 @@ export function Modals() {
|
||||
return budgetId ? <GoalTemplateModal key={key} /> : null;
|
||||
|
||||
case 'category-automations-edit':
|
||||
return budgetId ? <BudgetAutomationsModal key={name} /> : null;
|
||||
return budgetId ? (
|
||||
<BudgetAutomationsModal key={name} {...modal.options} />
|
||||
) : null;
|
||||
|
||||
case 'keyboard-shortcuts':
|
||||
// don't show the hotkey help modal when a budget is not open
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Popover } from '@actual-app/components/popover';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
@@ -27,6 +28,7 @@ type NotesButtonProps = {
|
||||
height?: number;
|
||||
defaultColor?: string;
|
||||
tooltipPosition?: ComponentProps<typeof Tooltip>['placement'];
|
||||
showPlaceholder?: boolean;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
export function NotesButton({
|
||||
@@ -35,6 +37,7 @@ export function NotesButton({
|
||||
height = 12,
|
||||
defaultColor = theme.buttonNormalText,
|
||||
tooltipPosition = 'bottom start',
|
||||
showPlaceholder = false,
|
||||
style,
|
||||
}: NotesButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -73,13 +76,19 @@ export function NotesButton({
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
aria-label={t('View notes')}
|
||||
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
|
||||
style={{
|
||||
color: defaultColor,
|
||||
...style,
|
||||
...(hasNotes && { display: 'flex !important' }),
|
||||
...(isOpen && { color: theme.buttonNormalText }),
|
||||
}}
|
||||
className={cx(
|
||||
css({
|
||||
color: defaultColor,
|
||||
...style,
|
||||
...(showPlaceholder && {
|
||||
opacity: hasNotes || isOpen ? 1 : 0.3,
|
||||
}),
|
||||
...(isOpen && { color: theme.buttonNormalText }),
|
||||
'&:hover': { opacity: 1 },
|
||||
}),
|
||||
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
|
||||
)}
|
||||
data-placeholder={showPlaceholder}
|
||||
onPress={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
type CategoryEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { CategoryAutomationButton } from './goals/CategoryAutomationButton';
|
||||
import { SidebarCategoryButtons } from './SidebarCategoryButtons';
|
||||
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { InputCell } from '@desktop-client/components/table';
|
||||
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
|
||||
type SidebarCategoryProps = {
|
||||
@@ -56,7 +54,6 @@ export function SidebarCategory({
|
||||
onHideNewCategory,
|
||||
}: SidebarCategoryProps) {
|
||||
const { t } = useTranslation();
|
||||
const isGoalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
|
||||
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
|
||||
const categoryExpandedState = categoryExpandedStatePref ?? 0;
|
||||
|
||||
@@ -128,22 +125,11 @@ export function SidebarCategory({
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
{!goalsShown && isGoalTemplatesUIEnabled && (
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<CategoryAutomationButton
|
||||
style={dragging && { color: 'currentColor' }}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<NotesButton
|
||||
id={category.id}
|
||||
style={dragging && { color: 'currentColor' }}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</View>
|
||||
<SidebarCategoryButtons
|
||||
category={category}
|
||||
dragging={dragging}
|
||||
goalsShown={goalsShown}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/types/models/category';
|
||||
|
||||
import { CategoryAutomationButton } from './goals/CategoryAutomationButton';
|
||||
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useNotes } from '@desktop-client/hooks/useNotes';
|
||||
|
||||
type SidebarCategoryButtonsProps = {
|
||||
category: CategoryEntity;
|
||||
dragging: boolean;
|
||||
goalsShown: boolean;
|
||||
};
|
||||
|
||||
export const SidebarCategoryButtons = ({
|
||||
category,
|
||||
dragging,
|
||||
goalsShown,
|
||||
}: SidebarCategoryButtonsProps) => {
|
||||
const isGoalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
|
||||
const notes = useNotes(category.id) || '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ flex: 1 }} />
|
||||
{!goalsShown && isGoalTemplatesUIEnabled && (
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<CategoryAutomationButton
|
||||
category={category}
|
||||
style={dragging ? { color: 'currentColor' } : undefined}
|
||||
defaultColor={theme.pageTextLight}
|
||||
showPlaceholder={!!notes}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<NotesButton
|
||||
id={category.id}
|
||||
style={dragging ? { color: 'currentColor' } : undefined}
|
||||
defaultColor={theme.pageTextLight}
|
||||
showPlaceholder={
|
||||
!goalsShown &&
|
||||
isGoalTemplatesUIEnabled &&
|
||||
!!category.goal_def?.length
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
@@ -20,7 +21,11 @@ import { ScheduleAutomation } from './editor/ScheduleAutomation';
|
||||
import { SimpleAutomation } from './editor/SimpleAutomation';
|
||||
import { WeekAutomation } from './editor/WeekAutomation';
|
||||
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import {
|
||||
FormField,
|
||||
FormLabel,
|
||||
FormTextLabel,
|
||||
} from '@desktop-client/components/forms';
|
||||
|
||||
type BudgetAutomationEditorProps = {
|
||||
inline: boolean;
|
||||
@@ -73,7 +78,7 @@ export function BudgetAutomationEditor({
|
||||
}: BudgetAutomationEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let automationEditor;
|
||||
let automationEditor: ReactNode;
|
||||
switch (state.displayType) {
|
||||
case 'simple':
|
||||
automationEditor = (
|
||||
@@ -144,7 +149,7 @@ export function BudgetAutomationEditor({
|
||||
</InitialFocus>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Description')} />
|
||||
<FormTextLabel title={t('Description')} />
|
||||
<Text>
|
||||
{displayTypeToDescription[state.displayType] ?? (
|
||||
<Trans>No description available</Trans>
|
||||
|
||||
@@ -4,34 +4,36 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgChartPie } from '@actual-app/components/icons/v1';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { cx, css } from '@emotion/css';
|
||||
|
||||
import { type Template } from 'loot-core/types/models/templates';
|
||||
import { type CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type CategoryAutomationButtonProps = {
|
||||
category: CategoryEntity;
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultColor?: string;
|
||||
style?: CSSProperties;
|
||||
showPlaceholder?: boolean;
|
||||
};
|
||||
export function CategoryAutomationButton({
|
||||
category,
|
||||
width = 12,
|
||||
height = 12,
|
||||
defaultColor = theme.buttonNormalText,
|
||||
style,
|
||||
showPlaceholder = false,
|
||||
}: CategoryAutomationButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const automations: Template[] = [];
|
||||
const hasAutomations = !!automations.length;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
|
||||
const hasAutomations = !!category.goal_def?.length;
|
||||
|
||||
if (!goalTemplatesEnabled || !goalTemplatesUIEnabled) {
|
||||
return null;
|
||||
@@ -41,14 +43,26 @@ export function CategoryAutomationButton({
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Change category automations')}
|
||||
className={!hasAutomations ? 'hover-visible' : ''}
|
||||
style={{
|
||||
color: defaultColor,
|
||||
...style,
|
||||
...(hasAutomations && { display: 'flex !important' }),
|
||||
}}
|
||||
className={cx(
|
||||
!hasAutomations && !showPlaceholder ? 'hover-visible' : '',
|
||||
css({
|
||||
color: defaultColor,
|
||||
opacity: hasAutomations || !showPlaceholder ? 1 : 0.3,
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
...style,
|
||||
}),
|
||||
)}
|
||||
onPress={() => {
|
||||
dispatch(pushModal({ modal: { name: 'category-automations-edit' } }));
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-automations-edit',
|
||||
options: { categoryId: category.id },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SvgChartPie style={{ width, height, flexShrink: 0 }} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
import type { SimpleTemplate } from 'loot-core/types/models/templates';
|
||||
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from '@desktop-client/components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { AmountInput } from '@desktop-client/components/util/AmountInput';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
type SimpleAutomationProps = {
|
||||
template: SimpleTemplate;
|
||||
@@ -19,6 +21,7 @@ export const SimpleAutomation = ({
|
||||
dispatch,
|
||||
}: SimpleAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { currency } = useFormat();
|
||||
|
||||
return (
|
||||
<FormField>
|
||||
@@ -26,10 +29,15 @@ export const SimpleAutomation = ({
|
||||
<AmountInput
|
||||
id="amount-field"
|
||||
key="amount-input"
|
||||
value={template.monthly ?? 0}
|
||||
value={amountToInteger(template.monthly ?? 0, currency.decimalPlaces)}
|
||||
zeroSign="+"
|
||||
onUpdate={(value: number) =>
|
||||
dispatch(updateTemplate({ type: 'simple', monthly: value }))
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'simple',
|
||||
monthly: integerToAmount(value, currency.decimalPlaces),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { integerToCurrency } from 'loot-core/shared/util';
|
||||
import { amountToInteger } from 'loot-core/shared/util';
|
||||
import type { SimpleTemplate } from 'loot-core/types/models/templates';
|
||||
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
type SimpleAutomationReadOnlyProps = {
|
||||
template: SimpleTemplate;
|
||||
};
|
||||
@@ -10,9 +12,17 @@ type SimpleAutomationReadOnlyProps = {
|
||||
export const SimpleAutomationReadOnly = ({
|
||||
template,
|
||||
}: SimpleAutomationReadOnlyProps) => {
|
||||
const format = useFormat();
|
||||
return (
|
||||
<Trans>
|
||||
Budget {{ monthly: integerToCurrency(template.monthly ?? 0) }} each month
|
||||
Budget{' '}
|
||||
{{
|
||||
monthly: format(
|
||||
amountToInteger(template.monthly ?? 0, format.currency.decimalPlaces),
|
||||
'financial',
|
||||
),
|
||||
}}{' '}
|
||||
each month
|
||||
</Trans>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
import type { PeriodicTemplate } from 'loot-core/types/models/templates';
|
||||
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from '@desktop-client/components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { AmountInput } from '@desktop-client/components/util/AmountInput';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
type WeekAutomationProps = {
|
||||
template: PeriodicTemplate;
|
||||
@@ -16,6 +18,7 @@ type WeekAutomationProps = {
|
||||
|
||||
export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { currency } = useFormat();
|
||||
|
||||
return (
|
||||
<FormField style={{ flex: 1 }}>
|
||||
@@ -23,10 +26,15 @@ export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
|
||||
<AmountInput
|
||||
id="amount-field"
|
||||
key="amount-input"
|
||||
value={template.amount ?? 0}
|
||||
value={amountToInteger(template.amount ?? 0, currency.decimalPlaces)}
|
||||
zeroSign="+"
|
||||
onUpdate={(value: number) =>
|
||||
dispatch(updateTemplate({ type: 'periodic', amount: value }))
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'periodic',
|
||||
amount: integerToAmount(value, currency.decimalPlaces),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { integerToCurrency } from 'loot-core/shared/util';
|
||||
import { amountToInteger } from 'loot-core/shared/util';
|
||||
import type { PeriodicTemplate } from 'loot-core/types/models/templates';
|
||||
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
type WeekAutomationReadOnlyProps = {
|
||||
template: PeriodicTemplate;
|
||||
};
|
||||
@@ -10,9 +12,18 @@ type WeekAutomationReadOnlyProps = {
|
||||
export const WeekAutomationReadOnly = ({
|
||||
template,
|
||||
}: WeekAutomationReadOnlyProps) => {
|
||||
const format = useFormat();
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
Budget {{ amount: integerToCurrency(template.amount) }} each week
|
||||
Budget{' '}
|
||||
{{
|
||||
amount: format(
|
||||
amountToInteger(template.amount, format.currency.decimalPlaces),
|
||||
'financial',
|
||||
),
|
||||
}}{' '}
|
||||
each week
|
||||
</Trans>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ const changeType = (
|
||||
template: {
|
||||
directive: 'template',
|
||||
type: 'simple',
|
||||
monthly: 500,
|
||||
monthly: 5,
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
};
|
||||
@@ -103,7 +103,7 @@ const changeType = (
|
||||
template: {
|
||||
directive: 'template',
|
||||
type: 'periodic',
|
||||
amount: 500,
|
||||
amount: 5,
|
||||
period: {
|
||||
period: 'week',
|
||||
amount: 1,
|
||||
|
||||
@@ -35,13 +35,17 @@ type FormLabelProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
const defaultLabelStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
marginBottom: 3,
|
||||
color: theme.tableText,
|
||||
};
|
||||
|
||||
export const FormLabel = ({ style, title, id, htmlFor }: FormLabelProps) => {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
marginBottom: 3,
|
||||
color: theme.tableText,
|
||||
...defaultLabelStyle,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
@@ -52,6 +56,24 @@ export const FormLabel = ({ style, title, id, htmlFor }: FormLabelProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const FormTextLabel = ({
|
||||
style,
|
||||
title,
|
||||
id,
|
||||
}: Omit<FormLabelProps, 'htmlFor'>) => {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
...defaultLabelStyle,
|
||||
cursor: 'default',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span id={id}>{title}</span>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
type FormFieldProps = {
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
|
||||
@@ -2,12 +2,19 @@ import { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { type Template } from 'loot-core/types/models/templates';
|
||||
import {
|
||||
type CategoryGroupEntity,
|
||||
type ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import type { Template } from 'loot-core/types/models/templates';
|
||||
|
||||
import { BudgetAutomation } from '@desktop-client/components/budget/goals/BudgetAutomation';
|
||||
import { DEFAULT_PRIORITY } from '@desktop-client/components/budget/goals/reducer';
|
||||
@@ -17,24 +24,87 @@ import {
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { useBudgetAutomations } from '@desktop-client/hooks/useBudgetAutomations';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
|
||||
type TemplateWithId = Template & { id: string };
|
||||
function BudgetAutomationList({
|
||||
automations,
|
||||
setAutomations,
|
||||
schedules,
|
||||
categories,
|
||||
}: {
|
||||
automations: Template[];
|
||||
setAutomations: (fn: (prev: Template[]) => Template[]) => void;
|
||||
schedules: readonly ScheduleEntity[];
|
||||
categories: CategoryGroupEntity[];
|
||||
}) {
|
||||
const [automationIds, setAutomationIds] = useState(() => {
|
||||
// automations don't have ids, so we need to generate them
|
||||
return automations.map(() => uniqueId('automation-'));
|
||||
});
|
||||
|
||||
export function BudgetAutomationsModal() {
|
||||
const onAdd = () => {
|
||||
const newId = uniqueId('automation-');
|
||||
setAutomationIds(prevIds => [...prevIds, newId]);
|
||||
setAutomations(prev => [
|
||||
...prev,
|
||||
{
|
||||
type: 'simple',
|
||||
monthly: 5,
|
||||
directive: 'template',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
]);
|
||||
};
|
||||
const onDelete = (index: number) => () => {
|
||||
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
|
||||
setAutomationIds(prev => [
|
||||
...prev.slice(0, index),
|
||||
...prev.slice(index + 1),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={4}
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
}}
|
||||
>
|
||||
{automations.map((automation, index) => (
|
||||
<BudgetAutomation
|
||||
key={automationIds[index]}
|
||||
onDelete={onDelete(index)}
|
||||
template={automation}
|
||||
categories={categories}
|
||||
schedules={schedules}
|
||||
readOnlyStyle={{
|
||||
color: theme.pillText,
|
||||
backgroundColor: theme.pillBackground,
|
||||
borderRadius: 4,
|
||||
padding: 16,
|
||||
paddingLeft: 30,
|
||||
paddingRight: 16,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Button onPress={onAdd}>
|
||||
<Trans>Add new automation</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// HACK: This is a placeholder for the actual data.
|
||||
// We should eventually load it using a data hook.
|
||||
const [templates, setTemplates] = useState<TemplateWithId[]>([
|
||||
{
|
||||
type: 'average',
|
||||
numMonths: 3,
|
||||
directive: 'template',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
id: uniqueId(),
|
||||
},
|
||||
]);
|
||||
const [automations, setAutomations] = useState<Record<string, Template[]>>(
|
||||
{},
|
||||
);
|
||||
const { loading } = useBudgetAutomations({
|
||||
categoryId,
|
||||
onLoaded: setAutomations,
|
||||
});
|
||||
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
const { schedules } = useSchedules({
|
||||
@@ -43,21 +113,22 @@ export function BudgetAutomationsModal() {
|
||||
|
||||
const categories = useBudgetAutomationCategories();
|
||||
|
||||
const onAdd = () => {
|
||||
setTemplates([
|
||||
...templates,
|
||||
{
|
||||
type: 'average',
|
||||
numMonths: 3,
|
||||
directive: 'template',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
id: uniqueId(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
const onSave = () => {};
|
||||
const onDelete = (index: number) => () => {
|
||||
setTemplates([...templates.slice(0, index), ...templates.slice(index + 1)]);
|
||||
const onSave = async (close: () => void) => {
|
||||
if (!automations[categoryId]) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
await send('budget/set-category-automations', {
|
||||
categoriesWithTemplates: [
|
||||
{
|
||||
id: categoryId,
|
||||
templates: automations[categoryId],
|
||||
},
|
||||
],
|
||||
source: 'ui',
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -68,40 +139,48 @@ export function BudgetAutomationsModal() {
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<Stack direction="column" style={{ height: '100%' }}>
|
||||
<ModalHeader
|
||||
title={t('Budget automations')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<Stack
|
||||
spacing={4}
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
}}
|
||||
>
|
||||
{templates.map((template, index) => (
|
||||
<BudgetAutomation
|
||||
key={template.id}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete(index)}
|
||||
template={template}
|
||||
categories={categories}
|
||||
schedules={schedules}
|
||||
readOnlyStyle={{
|
||||
color: theme.pillText,
|
||||
backgroundColor: theme.pillBackground,
|
||||
borderRadius: 4,
|
||||
padding: 16,
|
||||
paddingLeft: 30,
|
||||
paddingRight: 16,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Button onPress={onAdd}>
|
||||
<Trans>Add new automation</Trans>
|
||||
{loading ? (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
</View>
|
||||
) : (
|
||||
<BudgetAutomationList
|
||||
automations={automations[categoryId] || []}
|
||||
setAutomations={(cb: (prev: Template[]) => Template[]) => {
|
||||
setAutomations(prev => ({
|
||||
...prev,
|
||||
[categoryId]: cb(prev[categoryId] || []),
|
||||
}));
|
||||
}}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" justify="flex-end" style={{ marginTop: 20 }}>
|
||||
<Button onPress={close}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button variant="primary" onPress={() => onSave(close)}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
36
packages/desktop-client/src/hooks/useBudgetAutomations.ts
Normal file
36
packages/desktop-client/src/hooks/useBudgetAutomations.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import type { Template } from 'loot-core/types/models/templates';
|
||||
|
||||
export function useBudgetAutomations({
|
||||
categoryId,
|
||||
onLoaded,
|
||||
}: {
|
||||
categoryId: string;
|
||||
onLoaded: (automations: Record<string, Template[]>) => void;
|
||||
}) {
|
||||
const [automations, setAutomations] = useState<Record<string, Template[]>>(
|
||||
{},
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function fetchAutomations() {
|
||||
setLoading(true);
|
||||
const result = await send('budget/get-category-automations', categoryId);
|
||||
if (mounted) {
|
||||
setAutomations(result);
|
||||
onLoaded(result);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchAutomations();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [categoryId, onLoaded]);
|
||||
|
||||
return { automations, loading };
|
||||
}
|
||||
@@ -542,6 +542,9 @@ export type Modal =
|
||||
}
|
||||
| {
|
||||
name: 'category-automations-edit';
|
||||
options: {
|
||||
categoryId: CategoryEntity['id'];
|
||||
};
|
||||
};
|
||||
|
||||
type OpenAccountCloseModalPayload = {
|
||||
|
||||
Reference in New Issue
Block a user