import React, { useEffect, useReducer } from 'react'; import { useDispatch } from 'react-redux'; import { getPayeesById } from 'loot-core/client/reducers/queries'; import { pushModal } from 'loot-core/src/client/actions/modals'; import { runQuery, liveQuery } from 'loot-core/src/client/query-helpers'; import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { q } from 'loot-core/src/shared/query'; import { extractScheduleConds } from 'loot-core/src/shared/schedules'; import { useDateFormat } from '../../hooks/useDateFormat'; import { usePayees } from '../../hooks/usePayees'; import { useSelected, SelectedProvider } from '../../hooks/useSelected'; import { theme } from '../../style'; import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete'; import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete'; import { Button } from '../common/Button'; import { Modal } from '../common/Modal'; import { Stack } from '../common/Stack'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { FormField, FormLabel, Checkbox } from '../forms'; import { OpSelect } from '../modals/EditRule'; import { DateSelect } from '../select/DateSelect'; import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker'; import { SelectedItemsButton } from '../table'; import { SimpleTransactionsTable } from '../transactions/SimpleTransactionsTable'; import { AmountInput, BetweenAmountInput } from '../util/AmountInput'; import { GenericInput } from '../util/GenericInput'; function updateScheduleConditions(schedule, fields) { const conds = extractScheduleConds(schedule._conditions); const updateCond = (cond, op, field, value) => { if (cond) { return { ...cond, value }; } if (value != null) { return { op, field, value }; } return null; }; // Validate if (fields.date == null) { return { error: 'Date is required' }; } if (fields.amount == null) { return { error: 'A valid amount is required' }; } return { conditions: [ updateCond(conds.payee, 'is', 'payee', fields.payee), updateCond(conds.account, 'is', 'account', fields.account), updateCond(conds.date, 'isapprox', 'date', fields.date), // We don't use `updateCond` for amount because we want to // overwrite it completely { op: fields.amountOp, field: 'amount', value: fields.amount, }, ].filter(Boolean), }; } export function ScheduleDetails({ modalProps, actions, id, transaction }) { const adding = id == null; const fromTrans = transaction != null; const payees = getPayeesById(usePayees()); const globalDispatch = useDispatch(); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const [state, dispatch] = useReducer( (state, action) => { switch (action.type) { case 'set-schedule': { const schedule = action.schedule; // See if there are custom rules const conds = extractScheduleConds(schedule._conditions); const condsSet = new Set(Object.values(conds)); const isCustom = schedule._conditions.find(c => !condsSet.has(c)) || schedule._actions.find(a => a.op !== 'link-schedule'); return { ...state, schedule: action.schedule, isCustom, fields: { payee: schedule._payee, account: schedule._account, // defalut to a non-zero value so the sign can be changed before the value amount: schedule._amount || -1000, amountOp: schedule._amountOp || 'isapprox', date: schedule._date, posts_transaction: action.schedule.posts_transaction, name: schedule.name, }, }; } case 'set-field': if (!(action.field in state.fields)) { throw new Error('Unknown field: ' + action.field); } const fields = { [action.field]: action.value }; // If we are changing the amount operator either to or // away from the `isbetween` operator, the amount value is // different and we need to convert it if ( action.field === 'amountOp' && action.value !== state.fields.amountOp ) { if (action.value === 'isbetween') { // We need a range if switching to `isbetween`. The // amount field should be a number since we are // switching away from the other ops, but check just in // case fields.amount = typeof state.fields.amount === 'number' ? { num1: state.fields.amount, num2: state.fields.amount } : { num1: 0, num2: 0 }; } else if (state.fields.amountOp === 'isbetween') { // We need just a number if switching away from // `isbetween`. The amount field should be a range, but // also check just in case. We grab just the first // number and use it fields.amount = typeof state.fields.amount === 'number' ? state.fields.amount : state.fields.amount.num1; } } return { ...state, fields: { ...state.fields, ...fields }, }; case 'set-transactions': if (fromTrans && action.transactions) { action.transactions.sort(a => { return transaction.id === a.id ? -1 : 1; }); } return { ...state, transactions: action.transactions }; case 'set-repeats': return { ...state, fields: { ...state.fields, date: action.repeats ? { frequency: 'monthly', start: monthUtils.currentDay(), patterns: [], } : monthUtils.currentDay(), }, }; case 'set-upcoming-dates': return { ...state, upcomingDates: action.dates, }; case 'form-error': return { ...state, error: action.error }; case 'switch-transactions': return { ...state, transactionsMode: action.mode }; default: throw new Error('Unknown action: ' + action.type); } }, { schedule: null, upcomingDates: null, error: null, fields: { payee: null, account: null, amount: null, amountOp: null, date: null, posts_transaction: false, name: null, }, transactions: [], transactionsMode: adding ? 'matched' : 'linked', }, ); async function loadSchedule() { const { data } = await runQuery(q('schedules').filter({ id }).select('*')); return data[0]; } useEffect(() => { async function run() { if (adding) { const date = { start: monthUtils.currentDay(), frequency: 'monthly', patterns: [], skipWeekend: false, weekendSolveMode: 'after', endMode: 'never', endOccurrences: '1', endDate: monthUtils.currentDay(), }; const schedule = fromTrans ? { posts_transaction: false, _conditions: [{ op: 'isapprox', field: 'date', value: date }], _actions: [], _account: transaction.account, _amount: transaction.amount, _amountOp: 'is', name: transaction.payee ? payees[transaction.payee].name : '', _payee: transaction.payee ? transaction.payee : '', _date: { ...date, frequency: 'monthly', start: transaction.date, patterns: [], }, } : { posts_transaction: false, _date: date, _conditions: [{ op: 'isapprox', field: 'date', value: date }], _actions: [], }; dispatch({ type: 'set-schedule', schedule }); } else { const schedule = await loadSchedule(); if (schedule && state.schedule == null) { dispatch({ type: 'set-schedule', schedule }); } } } run(); }, []); useEffect(() => { async function run() { const date = state.fields.date; if (date == null) { dispatch({ type: 'set-upcoming-dates', dates: null }); } else { if (date.frequency) { const { data } = await sendCatch('schedule/get-upcoming-dates', { config: date, count: 3, }); dispatch({ type: 'set-upcoming-dates', dates: data }); } else { const today = monthUtils.currentDay(); if (date === today || monthUtils.isAfter(date, today)) { dispatch({ type: 'set-upcoming-dates', dates: [date] }); } else { dispatch({ type: 'set-upcoming-dates', dates: null }); } } } } run(); }, [state.fields.date]); useEffect(() => { if ( state.schedule && state.schedule.id && state.transactionsMode === 'linked' ) { const live = liveQuery( q('transactions') .filter({ schedule: state.schedule.id }) .select('*') .options({ splits: 'all' }), data => dispatch({ type: 'set-transactions', transactions: data }), ); return live.unsubscribe; } }, [state.schedule, state.transactionsMode]); useEffect(() => { let current = true; let unsubscribe; if (state.schedule && state.transactionsMode === 'matched') { const { error, conditions: originalConditions } = updateScheduleConditions(state.schedule, state.fields); if (error) { dispatch({ type: 'form-error', error }); return; } // *Extremely* gross hack because the rules are not mapped to // public names automatically. We really should be doing that // at the database layer const conditions = originalConditions.map(cond => { if (cond.field === 'description') { return { ...cond, field: 'payee' }; } else if (cond.field === 'acct') { return { ...cond, field: 'account' }; } return cond; }); send('make-filters-from-conditions', { conditions, }).then(({ filters }) => { if (current) { const live = liveQuery( q('transactions') .filter({ $and: filters }) .select('*') .options({ splits: 'all' }), data => dispatch({ type: 'set-transactions', transactions: data }), ); unsubscribe = live.unsubscribe; } }); } return () => { current = false; if (unsubscribe) { unsubscribe(); } }; }, [state.schedule, state.transactionsMode, state.fields]); const selectedInst = useSelected( 'transactions', state.transactions, transaction ? [transaction.id] : [], ); async function onSave() { dispatch({ type: 'form-error', error: null }); if (state.fields.name) { const { data: sameName } = await runQuery( q('schedules').filter({ name: state.fields.name }).select('id'), ); if (sameName.length > 0 && sameName[0].id !== state.schedule.id) { dispatch({ type: 'form-error', error: 'There is already a schedule with this name', }); return; } } const { error, conditions } = updateScheduleConditions( state.schedule, state.fields, ); if (error) { dispatch({ type: 'form-error', error }); return; } const res = await sendCatch( adding ? 'schedule/create' : 'schedule/update', { schedule: { id: state.schedule.id, posts_transaction: state.fields.posts_transaction, name: state.fields.name, }, conditions, }, ); if (res.error) { dispatch({ type: 'form-error', error: 'An error occurred while saving. Please visit https://actualbudget.org/contact/ for support.', }); } else { if (adding) { await onLinkTransactions([...selectedInst.items], res.data); } actions.popModal(); } } async function onEditRule(ruleId) { const rule = await send('rule-get', { id: ruleId || state.schedule.rule }); globalDispatch( pushModal('edit-rule', { rule, onSave: async () => { const schedule = await loadSchedule(); dispatch({ type: 'set-schedule', schedule }); }, }), ); } async function onLinkTransactions(ids, scheduleId) { await send('transactions-batch-update', { updated: ids.map(id => ({ id, schedule: scheduleId || state.schedule.id, })), }); selectedInst.dispatch({ type: 'select-none' }); } async function onUnlinkTransactions(ids) { await send('transactions-batch-update', { updated: ids.map(id => ({ id, schedule: null })), }); selectedInst.dispatch({ type: 'select-none' }); } if (state.schedule == null) { return null; } function onSwitchTransactions(mode) { dispatch({ type: 'switch-transactions', mode }); selectedInst.dispatch({ type: 'select-none' }); } const payee = payees ? payees[state.fields.payee] : null; // This is derived from the date const repeats = state.fields.date ? !!state.fields.date.frequency : false; return ( <Modal title={payee ? `Schedule: ${payee.name}` : 'Schedule'} size="medium" {...modalProps} > <Stack direction="row" style={{ marginTop: 10 }}> <FormField style={{ flex: 1 }}> <FormLabel title="Schedule Name" htmlFor="name-field" /> <GenericInput field="string" type="string" value={state.fields.name} multi={false} onChange={e => { dispatch({ type: 'set-field', field: 'name', value: e }); }} /> </FormField> </Stack> <Stack direction="row" style={{ marginTop: 20 }}> <FormField style={{ flex: 1 }}> <FormLabel title="Payee" id="payee-label" htmlFor="payee-field" /> <PayeeAutocomplete value={state.fields.payee} labelProps={{ id: 'payee-label' }} inputProps={{ id: 'payee-field', placeholder: '(none)' }} onSelect={id => dispatch({ type: 'set-field', field: 'payee', value: id }) } isCreatable /> </FormField> <FormField style={{ flex: 1 }}> <FormLabel title="Account" id="account-label" htmlFor="account-field" /> <AccountAutocomplete includeClosedAccounts={false} value={state.fields.account} labelProps={{ id: 'account-label' }} inputProps={{ id: 'account-field', placeholder: '(none)' }} onSelect={id => dispatch({ type: 'set-field', field: 'account', value: id }) } /> </FormField> <FormField style={{ flex: 1 }}> <Stack direction="row" align="center" style={{ marginBottom: 3 }}> <FormLabel title="Amount" htmlFor="amount-field" style={{ margin: 0, flex: 1 }} /> <OpSelect ops={['isapprox', 'is', 'isbetween']} value={state.fields.amountOp} formatOp={op => { switch (op) { case 'is': return 'is exactly'; case 'isapprox': return 'is approximately'; case 'isbetween': return 'is between'; default: throw new Error('Invalid op for select: ' + op); } }} style={{ padding: '0 10px', color: theme.pageTextLight, fontSize: 12, }} onChange={(_, op) => dispatch({ type: 'set-field', field: 'amountOp', value: op }) } /> </Stack> {state.fields.amountOp === 'isbetween' ? ( <BetweenAmountInput defaultValue={state.fields.amount} onChange={value => dispatch({ type: 'set-field', field: 'amount', value, }) } /> ) : ( <AmountInput id="amount-field" value={state.fields.amount} onUpdate={value => dispatch({ type: 'set-field', field: 'amount', value, }) } /> )} </FormField> </Stack> <View style={{ marginTop: 20 }}> <FormLabel title="Date" /> </View> <Stack direction="row" align="flex-start" justify="space-between"> <View style={{ width: '13.44rem' }}> {repeats ? ( <RecurringSchedulePicker value={state.fields.date} onChange={value => dispatch({ type: 'set-field', field: 'date', value }) } /> ) : ( <DateSelect value={state.fields.date} onSelect={date => dispatch({ type: 'set-field', field: 'date', value: date }) } dateFormat={dateFormat} /> )} {state.upcomingDates && ( <View style={{ fontSize: 13, marginTop: 20 }}> <Text style={{ color: theme.pageTextLight, fontWeight: 600 }}> Upcoming dates </Text> <Stack direction="column" spacing={1} style={{ marginTop: 10, color: theme.pageTextLight }} > {state.upcomingDates.map(date => ( <View key={date}> {monthUtils.format(date, `${dateFormat} EEEE`)} </View> ))} </Stack> </View> )} </View> <View style={{ marginTop: 5, flexDirection: 'row', alignItems: 'center', userSelect: 'none', }} > <Checkbox id="form_repeats" checked={repeats} onChange={e => { dispatch({ type: 'set-repeats', repeats: e.target.checked }); }} /> <label htmlFor="form_repeats" style={{ userSelect: 'none' }}> Repeats </label> </View> <Stack align="flex-end"> <View style={{ marginTop: 5, flexDirection: 'row', alignItems: 'center', userSelect: 'none', justifyContent: 'flex-end', }} > <Checkbox id="form_posts_transaction" checked={state.fields.posts_transaction} onChange={e => { dispatch({ type: 'set-field', field: 'posts_transaction', value: e.target.checked, }); }} /> <label htmlFor="form_posts_transaction" style={{ userSelect: 'none' }} > Automatically add transaction </label> </View> <Text style={{ width: 350, textAlign: 'right', color: theme.pageTextLight, marginTop: 10, fontSize: 13, lineHeight: '1.4em', }} > If checked, the schedule will automatically create transactions for you in the specified account </Text> {!adding && state.schedule.rule && ( <Stack direction="row" align="center" style={{ marginTop: 20 }}> {state.isCustom && ( <Text style={{ color: theme.pageTextLight, fontSize: 13, textAlign: 'right', width: 350, }} > This schedule has custom conditions and actions </Text> )} <Button onClick={() => onEditRule()} disabled={adding}> Edit as rule </Button> </Stack> )} </Stack> </Stack> <View style={{ marginTop: 30, flex: 1 }}> <SelectedProvider instance={selectedInst}> {adding ? ( <View style={{ flexDirection: 'row', padding: '5px 0' }}> <Text style={{ color: theme.pageTextLight }}> These transactions match this schedule: </Text> <View style={{ flex: 1 }} /> <Text style={{ color: theme.pageTextLight }}> Select transactions to link on save </Text> </View> ) : ( <View style={{ flexDirection: 'row', alignItems: 'center' }}> <Button type="bare" style={{ color: state.transactionsMode === 'linked' ? theme.pageTextLink : theme.pageTextSubdued, marginRight: 10, fontSize: 14, }} onClick={() => onSwitchTransactions('linked')} > Linked transactions </Button>{' '} <Button type="bare" style={{ color: state.transactionsMode === 'matched' ? theme.pageTextLink : theme.pageTextSubdued, fontSize: 14, }} onClick={() => onSwitchTransactions('matched')} > Find matching transactions </Button> <View style={{ flex: 1 }} /> <SelectedItemsButton name="transactions" items={ state.transactionsMode === 'linked' ? [{ name: 'unlink', text: 'Unlink from schedule' }] : [{ name: 'link', text: 'Link to schedule' }] } onSelect={(name, ids) => { switch (name) { case 'link': onLinkTransactions(ids); break; case 'unlink': onUnlinkTransactions(ids); break; default: } }} /> </View> )} <SimpleTransactionsTable renderEmpty={ <NoTransactionsMessage error={state.error} transactionsMode={state.transactionsMode} /> } transactions={state.transactions} fields={['date', 'payee', 'amount']} style={{ border: '1px solid ' + theme.tableBorder, borderRadius: 4, overflow: 'hidden', marginTop: 5, maxHeight: 200, }} /> </SelectedProvider> </View> <Stack direction="row" justify="flex-end" align="center" style={{ marginTop: 20 }} > {state.error && ( <Text style={{ color: theme.errorText }}>{state.error}</Text> )} <Button style={{ marginRight: 10 }} onClick={actions.popModal}> Cancel </Button> <Button type="primary" onClick={onSave}> {adding ? 'Add' : 'Save'} </Button> </Stack> </Modal> ); } function NoTransactionsMessage(props) { return ( <View style={{ padding: 20, color: theme.pageTextLight, textAlign: 'center', }} > {props.error ? ( <Text style={{ color: theme.errorText }}> Could not search: {props.error} </Text> ) : props.transactionsMode === 'matched' ? ( 'No matching transactions' ) : ( 'No linked transactions' )} </View> ); }