Commit a2fd660b authored by Nicolas Frandeboeuf's avatar Nicolas Frandeboeuf

Store budgets by year/month instead of globally

parent 1852f1bb
Pipeline #88124 passed with stages
in 9 minutes and 10 seconds
......@@ -13,35 +13,22 @@ class Budget extends React.Component {
constructor(props) {
super(props);
let date = new Date();
let periodDate = moment({ year: date.getFullYear(), month: date.getMonth(), day: 1 });
this.state = {
fromDate: periodDate.toDate(),
toDate: periodDate.endOf('month').toDate(),
showCatWithoutThreshold: this.props.displayNoThreshold,
showBudgetWithoutThreshold: this.props.displayNoThreshold,
displayInPercent: this.props.displayPercent
};
}
handleChange = event => {
let period = event.currentTarget.value.split('-');
let periodDate = moment({
year: parseInt(period[0], 10),
month: parseInt(period[1], 10),
day: 1
});
this.setState({
fromDate: periodDate.toDate(),
toDate: periodDate.endOf('month').toDate()
});
this.props.setPeriod(parseInt(period[0], 10), parseInt(period[1], 10));
};
handleToggleWithoutThreshold = () => {
let newValue = !this.state.showCatWithoutThreshold;
let newValue = !this.state.showBudgetWithoutThreshold;
this.props.updateDisplayNoThreshold(newValue);
this.setState({
showCatWithoutThreshold: newValue
showBudgetWithoutThreshold: newValue
});
};
......@@ -54,53 +41,80 @@ class Budget extends React.Component {
};
showOperations = catId => {
this.props.showOperations(catId, this.state.fromDate, this.state.toDate);
let periodDate = moment({ year: this.props.year, month: this.props.month, day: 1 });
let fromDate = periodDate.toDate();
let toDate = periodDate.endOf('month').toDate();
this.props.showOperations(catId, fromDate, toDate);
};
render() {
let { fromDate, toDate } = this.state;
let dateFilter = op => op.budgetDate >= fromDate && op.budgetDate <= toDate;
let operations = this.props.operations.filter(dateFilter);
let categoriesToShow = this.props.categories;
if (!this.state.showCatWithoutThreshold) {
categoriesToShow = categoriesToShow.filter(cat => cat.threshold !== 0);
componentDidMount() {
if (!this.props.budgets) {
this.props.fetchBudgets(this.props.year, this.props.month);
}
}
render() {
let sumAmounts = 0;
let sumThresholds = 0;
let items = categoriesToShow.map(cat => {
let catOps = operations.filter(op => cat.id === op.categoryId);
let amount = catOps.reduce((acc, op) => acc + op.amount, 0);
let remaining = '-';
let items = null;
sumAmounts += amount;
sumThresholds += cat.threshold;
if (this.props.budgets) {
let periodDate = moment({ year: this.props.year, month: this.props.month, day: 1 });
let fromDate = periodDate.toDate();
let toDate = periodDate.endOf('month').toDate();
return (
<BudgetListItem
key={cat.id}
cat={cat}
amount={parseFloat(amount.toFixed(2))}
updateCategory={this.props.updateCategory}
showOperations={this.showOperations}
displayInPercent={this.state.displayInPercent}
currentAccountId={this.props.currentAccountId}
/>
);
});
let dateFilter = op => op.budgetDate >= fromDate && op.budgetDate <= toDate;
let operations = this.props.operations.filter(dateFilter);
let remaining = '-';
if (sumAmounts) {
if (this.state.displayInPercent) {
if (sumThresholds) {
remaining = (100 * (sumAmounts - sumThresholds)) / sumThresholds;
remaining = `${remaining.toFixed(2)}%`;
let budgetsToShow = this.props.budgets;
if (!this.state.showBudgetWithoutThreshold) {
budgetsToShow = budgetsToShow.filter(budget => budget.threshold !== 0);
}
items = budgetsToShow.map(budget => {
let catOps = operations.filter(op => budget.categoryId === op.categoryId);
let amount = catOps.reduce((acc, op) => acc + op.amount, 0);
sumAmounts += amount;
sumThresholds += budget.threshold;
let key = `${budget.categoryId}${budget.year}${budget.month}`;
return (
<BudgetListItem
key={key}
id={key}
budget={budget}
amount={parseFloat(amount.toFixed(2))}
updateBudget={this.props.updateBudget}
showOperations={this.showOperations}
displayInPercent={this.state.displayInPercent}
currentAccountId={this.props.currentAccountId}
/>
);
});
if (sumAmounts) {
if (this.state.displayInPercent) {
if (sumThresholds) {
remaining = (100 * (sumAmounts - sumThresholds)) / sumThresholds;
remaining = `${remaining.toFixed(2)}%`;
} else {
remaining = '-';
}
} else {
remaining = '-';
remaining = (sumAmounts - sumThresholds).toFixed(2);
}
} else {
remaining = (sumAmounts - sumThresholds).toFixed(2);
}
} else {
items = (
<tr>
<td colSpan="5">
<i className="fa fa-spinner" />
</td>
</tr>
);
}
let currentDate = new Date();
......@@ -132,7 +146,7 @@ class Budget extends React.Component {
<select
onChange={this.handleChange}
defaultValue={`${currentYear}-${currentMonth}`}>
defaultValue={`${this.props.year}-${this.props.month}`}>
{months}
</select>
</p>
......@@ -142,7 +156,7 @@ class Budget extends React.Component {
<input
type="checkbox"
onChange={this.handleToggleWithoutThreshold}
checked={this.state.showCatWithoutThreshold}
checked={this.state.showBudgetWithoutThreshold}
/>
</label>
</p>
......@@ -174,8 +188,8 @@ class Budget extends React.Component {
<th className="category-button">&nbsp;</th>
</tr>
</thead>
<tbody>
{items}
<tbody>{items}</tbody>
<tfoot>
<tr>
<th className="category-name">{$t('client.budget.total')}</th>
<th className="category-amount amount">{sumAmounts.toFixed(2)}</th>
......@@ -185,7 +199,7 @@ class Budget extends React.Component {
<th className="category-diff amount">{remaining}</th>
<th className="category-button">&nbsp;</th>
</tr>
</tbody>
</tfoot>
</table>
</div>
);
......@@ -193,14 +207,20 @@ class Budget extends React.Component {
}
Budget.propTypes = {
// The list of categories.
categories: PropTypes.array.isRequired,
// The current displayed year.
year: PropTypes.number.isRequired,
// The current displayed month.
month: PropTypes.number.isRequired,
// The list of budgets.
budgets: PropTypes.array,
// The list of current operations.
operations: PropTypes.array.isRequired,
// The method to update a category.
updateCategory: PropTypes.func.isRequired,
// The method to update a budget.
updateBudget: PropTypes.func.isRequired,
// A method to display the reports component inside the main app, pre-filled
// with the year/month and category filters.
......@@ -251,8 +271,13 @@ const Export = connect(
let displayPercent = get.boolSetting(state, 'budgetDisplayPercent');
let displayNoThreshold = get.boolSetting(state, 'budgetDisplayNoThreshold');
let { year: selectedYear, month: selectedMonth } = get.budgetSelectedPeriod(state);
let budgets = get.budgetsFromSelectedPeriod(state);
return {
categories: get.categoriesButNone(state),
year: selectedYear,
month: selectedMonth,
budgets,
operations,
periods,
currentAccountId,
......@@ -262,8 +287,16 @@ const Export = connect(
},
dispatch => {
return {
updateCategory(former, newer) {
actions.updateCategory(dispatch, former, newer);
setPeriod(year, month) {
actions.setBudgetsPeriod(dispatch, year, month);
},
fetchBudgets(year, month) {
actions.fetchBudgetsByYearMonth(dispatch, year, month);
},
updateBudget(former, newer) {
actions.updateBudget(dispatch, former, newer);
},
showOperations(categoryId, fromDate, toDate) {
......
......@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { actions } from '../../store';
import { get, actions } from '../../store';
import { round2, translate as $t } from '../../helpers';
......@@ -104,27 +104,28 @@ export function getBars(threshold, amount, warningThresholdInPct) {
class BudgetListItem extends React.Component {
handleChange = threshold => {
if (this.props.cat.threshold === threshold || Number.isNaN(threshold)) {
if (this.props.budget.threshold === threshold || Number.isNaN(threshold)) {
return;
}
let category = {
title: this.props.cat.title,
color: this.props.cat.color,
let budget = {
categoryId: this.props.budget.categoryId,
year: this.props.budget.year,
month: this.props.budget.month,
threshold
};
this.props.updateCategory(this.props.cat, category);
this.props.updateBudget(this.props.budget, budget);
};
handleViewOperations = () => {
this.props.showOperations(this.props.cat.id);
this.props.showOperations(this.props.category.id);
this.props.showSearchDetails();
};
render() {
let { cat: category, amount } = this.props;
let threshold = category.threshold;
let { category, amount, budget } = this.props;
let threshold = budget.threshold;
const amountPct = computeAmountRatio(amount, threshold);
let amountText = amount;
......@@ -163,7 +164,7 @@ class BudgetListItem extends React.Component {
}
return (
<tr key={category.id}>
<tr>
<td className="category-name">
<span className="color-block-small" style={{ backgroundColor: category.color }}>
&nbsp;
......@@ -183,8 +184,8 @@ class BudgetListItem extends React.Component {
onInput={this.handleChange}
defaultValue={Math.abs(threshold)}
initiallyNegative={threshold < 0}
signId={`sign-${category.id}`}
className="block"
signId={`sign-${this.props.id}`}
/>
</td>
<td className="category-diff amount">{remainingText}</td>
......@@ -204,17 +205,17 @@ class BudgetListItem extends React.Component {
}
BudgetListItem.propTypes = {
// The category related to this budget item.
cat: PropTypes.object.isRequired,
// The total amount
amount: PropTypes.number.isRequired,
// The budget item.
budget: PropTypes.object.isRequired,
// Whether to display in percent or not
displayInPercent: PropTypes.bool.isRequired,
// The method to update a category.
updateCategory: PropTypes.func.isRequired,
// The method to update a budget.
updateBudget: PropTypes.func.isRequired,
// A method to display the reports component inside the main app, pre-filled
// with the year/month and category filters.
......@@ -225,9 +226,19 @@ BudgetListItem.propTypes = {
};
const Export = connect(
null,
(state, ownProps) => {
const category = get.categoryById(state, ownProps.budget.categoryId);
return {
category,
...ownProps
};
},
dispatch => ({
showSearchDetails: () => actions.toggleSearchDetails(dispatch, true)
showSearchDetails: () => actions.toggleSearchDetails(dispatch, true),
updateBudget: (former, newer) => {
actions.updateBudget(dispatch, former, newer);
}
})
)(BudgetListItem);
export default Export;
......@@ -9,7 +9,7 @@ import {
UNKNOWN_OPERATION_TYPE
} from './helpers';
import { checkAlert } from '../shared/validators';
import { checkAlert, checkBudget } from '../shared/validators';
export class Access {
constructor(arg, banks) {
......@@ -113,6 +113,16 @@ export class Category {
constructor(arg) {
this.title = assertHas(arg, 'title') && arg.title;
this.color = (maybeHas(arg, 'color') && arg.color) || stringToColor(this.title);
this.id = assertHas(arg, 'id') && arg.id;
// Optional
this.parentId = arg.parentId;
}
}
export class Budget {
constructor(arg) {
this.categoryId = assertHas(arg, 'categoryId') && arg.categoryId;
let threshold = 0;
if (maybeHas(arg, 'threshold')) {
threshold = arg.threshold;
......@@ -124,9 +134,10 @@ export class Category {
}
}
this.threshold = threshold;
this.id = assertHas(arg, 'id') && arg.id;
// Optional
this.parentId = arg.parentId;
this.year = assertHas(arg, 'year') && arg.year;
this.month = assertHas(arg, 'month') && arg.month;
assert(!checkBudget(this));
}
}
......
......@@ -32,6 +32,12 @@ export const CREATE_CATEGORY = 'CREATE_CATEGORY';
export const DELETE_CATEGORY = 'DELETE_CATEGORY';
export const UPDATE_CATEGORY = 'UPDATE_CATEGORY';
// Budgets
export const SET_BUDGETS_PERIOD = 'SET_BUDGETS_PERIOD';
export const FETCH_BUDGETS = 'FETCH_BUDGETS';
export const UPDATE_BUDGET = 'UPDATE_BUDGET';
export const RESET_BUDGETS = 'RESET_BUDGETS';
// Settings
export const EXPORT_INSTANCE = 'EXPORT_INSTANCE';
export const IMPORT_INSTANCE = 'IMPORT_INSTANCE';
......
......@@ -322,6 +322,21 @@ export function updateCategory(id, category) {
});
}
export function fetchBudgets(year, month) {
return buildFetchPromise(`api/${API_VERSION}/budgets/${year}/${month}`);
}
export function updateBudget(budget) {
const { categoryId, year, month } = budget;
return buildFetchPromise(`api/${API_VERSION}/budgets/${categoryId}/${year}/${month}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(budget)
});
}
export function fetchLogs() {
return buildFetchPromise(`api/${API_VERSION}/logs`);
}
import u from 'updeep';
import { assert, assertHas } from '../helpers';
import { Budget } from '../models';
import * as backend from './backend';
import { createReducerFromMap, fillOutcomeHandlers, SUCCESS, FAIL, updateMapIf } from './helpers';
import { SET_BUDGETS_PERIOD, FETCH_BUDGETS, UPDATE_BUDGET, RESET_BUDGETS } from './actions';
import { genericErrorHandler } from '../errors';
// Basic actions creators
const basic = {
setPeriod(year, month) {
return {
type: SET_BUDGETS_PERIOD,
year,
month
};
},
fetchBudgets(budgets) {
return {
type: FETCH_BUDGETS,
budgets: budgets.budgets,
year: budgets.year,
month: budgets.month
};
},
updateBudget(former, budget) {
return {
type: UPDATE_BUDGET,
former,
budget
};
},
resetBudgets() {
return {
type: RESET_BUDGETS
};
}
};
const fail = {},
success = {};
fillOutcomeHandlers(basic, fail, success);
export function update(former, budget) {
assert(former instanceof Budget, 'UpdateBudget first arg must be a Budget');
assertHas(budget, 'year', 'UpdateBudget second arg must have a year field');
assertHas(budget, 'month', 'UpdateBudget second arg must have a month field');
assert(
typeof budget.threshold === 'number',
'threshold',
'UpdateBudget second arg must have a threshold field'
);
assertHas(budget, 'categoryId', 'UpdateBudget second arg must have a categoryId field');
return dispatch => {
dispatch(basic.updateBudget(former, budget));
backend
.updateBudget(budget)
.then(() => {
dispatch(success.updateBudget(former, budget));
})
.catch(err => {
dispatch(fail.updateBudget(err, former, budget));
});
};
}
// States
let currentDate = new Date();
const budgetState = u(
{
// The budgets themselves.
budgetsByPeriod: {},
// The selected year
year: currentDate.getFullYear(),
// The selected month
month: currentDate.getMonth()
},
{}
);
// Reducers
function reduceSetPeriod(state, action) {
return u(
{
year: action.year,
month: action.month
},
state
);
}
function reduceFetch(state, action) {
let { status } = action;
if (status === SUCCESS) {
return u(
{
budgetsByPeriod: {
[`${action.year}${action.month}`]: action.budgets.map(b => new Budget(b))
}
},
state
);
}
if (status === FAIL) {
genericErrorHandler(action.error);
return u(
{
budgetsByPeriod: {
[`${action.year}${action.month}`]: null
}
},
state
);
}
return state;
}
function reduceUpdate(state, action) {
let { status } = action;
if (status === SUCCESS) {
let updated = action.budget;
return u(
{
budgetsByPeriod: {
[`${updated.year}${updated.month}`]: updateMapIf(
'categoryId',
updated.categoryId,
budget => new Budget(u(updated, budget))
)
}
},
state
);
}
if (status === FAIL) {
genericErrorHandler(action.error);
}
return state;
}
function reduceReset(state) {
return u(
{
budgetsByPeriod: u.constant({})
},
state
);
}
const reducers = {
SET_BUDGETS_PERIOD: reduceSetPeriod,
FETCH_BUDGETS: reduceFetch,
UPDATE_BUDGET: reduceUpdate,
RESET_BUDGETS: reduceReset
};
export const reducer = createReducerFromMap(budgetState, reducers);
// Initial state
export function initialState() {
return u(
{
budgetsByPeriod: {}
},
{}
);
}
export function getSelectedPeriod(state) {
return { year: state.year, month: state.month };
}
export function fromSelectedPeriod(state) {
return state.budgetsByPeriod[`${state.year}${state.month}`] || null;
}
function _fetchFromYearAndMonth(dispatch, year, month) {
backend
.fetchBudgets(year, month)
.then(result => {
dispatch(success.fetchBudgets(result));
})
.catch(err => {
dispatch(fail.fetchBudgets(err));
});
}
export function setSelectedPeriod(year, month) {
return (dispatch, getState) => {
dispatch(basic.setPeriod(year, month));
if (!fromSelectedPeriod(getState().budgets)) {
_fetchFromYearAndMonth(dispatch, year, month);
}
};
}
export function fetchFromYearAndMonth(year, month) {
return dispatch => _fetchFromYearAndMonth(dispatch, year, month);
}
export function reset() {
return dispatch => dispatch(basic.resetBudgets());
}
......@@ -4,6 +4,7 @@ import reduxThunk from 'redux-thunk';
import * as Bank from './banks';
import * as Category from './categories';
import * as Budget from './budgets';
import * as Settings from './settings';
import * as OperationType from './operation-types';
import * as Ui from './ui';
......@@ -35,6 +36,7 @@ function augmentReducer(reducer, field) {
const rootReducer = combineReducers({
banks: augmentReducer(Bank.reducer, 'banks'),
categories: augmentReducer(Category.reducer, 'categories'),
budgets: augmentReducer(Budget.reducer, 'budgets'),
settings: augmentReducer(Settings.reducer, 'settings'),
ui: augmentReducer(Ui.reducer, 'ui'),
// Static information
......@@ -230,6 +232,18 @@ export const get = {
return Category.fromId(state.categories, id);
},
// *** Budgets ************************************************************
budgetSelectedPeriod(state) {
assertDefined(state);
return Budget.getSelectedPeriod(state.budgets);
},
// [Budget]
budgetsFromSelectedPeriod(state) {
assertDefined(state);
return Budget.fromSelectedPeriod(state.budgets);
},
// *** Settings ***********************************************************
// String
setting(state, key) {
......@@ -328,11 +342,13 @@ export const actions = {
// *** Categories *********************************************************
createCategory(dispatch, category) {
assertDefined(dispatch);
dispatch(Budget.reset());
return dispatch(Category.create(category));
},
createDefaultCategories(dispatch) {
assertDefined(dispatch);
dispatch(Budget.reset());
dispatch(Category.createDefault());
},
......@@ -343,9 +359,27 @@ export const actions = {
deleteCategory(dispatch, formerId, replaceById) {
assertDefined(dispatch);
// Reset the budgets so a new fetch will occur, ensuring everything is up-to-date
dispatch(Budget.reset());
dispatch(Category.destroy(formerId, replaceById));
},