Глядя на свой банковский счет в конце месяца, каждый из нас хоть раз задавался вопросом: “А куда ушли все мои деньги?”. Отслеживание расходов — это непростая задача. Иногда бывают незапланированные траты на дружеские посиделки или спонтанные покупки. Хорошим решением будет сохранять чеки, чтобы видеть общую картину своих расходов. Здесь на помощь приходят приложения для учета финансов. Подобные инструменты позволяют легко проанализировать, сколько вы тратите на определенные категории: еда, транспорт, подарки, уход за домашними питомцами и т.д.
И сегодня я покажу вам, как создать подобное приложение для учета финансов с помощью компонентов Webix.
С кодом готового приложения и живой демкой можно ознакомиться здесь.
Обзор приложения
Основу приложения будет составлять список доходов и расходов. У каждой записи из списка будет тип (доход или расход), категория, дата и значение. Пользователи смогут отфильтровать данные по датам с помощью фильтров «This month», «This year», «Last 12 months» и «All». Наглядно показывать суммы по категориям будут две диаграммы — «Expenses» и «Income».
Для сбора пользовательских данных мы будем использовать виджет Form, для представления записей — List. Визуализацию данных мы реализуем с помощью виджета Chart.
У нашего приложения будет два режима отображения: полноэкранный и компактный. В полноэкранном режиме все виджеты будут находиться на одной странице. В компактном вы сможете переключаться между тремя вкладками: «Form», «Expense» и «Income». Для этого мы воспользуемся компонентом Multiview.
В браузере наше приложение будет выглядеть так:
-
- полноэкранный режим
-
- компактный режим
Подготавливаем почву
Для начала давайте подключим библиотеку Webix. Для этого приложения нам подойдет стандартная GPL-версия. Она доступна для загрузки здесь или через CDN по следующим ссылкам:
Теперь давайте рассмотрим, как создать необходимые компоненты интерфейса.
Создаем лейаут
Как я уже говорила, у приложения будет два лейаута: один для полноэкранного режима, другой для компактного. Для хранения каждого из них определим переменные smallUI и bigUI. Чтобы сделать интерфейс более понятным, все виджеты тоже будем хранить в отдельных переменных.
id: "top",
rows: [
multiview,
{
type: "form",
rows: [select],
},
tabbar,
],
};
const bigUI = {
id: "top",
type: "space",
cols: [
{
maxWidth: 300,
type: "wide",
rows: [form, list],
},
{
type: "wide",
rows: [
radio,
{
rows: [
{
template: "Income",
type: "header",
},
chartIncome,
],
},
{
rows: [
{
template: "Expenses",
type: "header",
},
chartExpenses,
],
},
],
},
],
};
Настраиваем лейауты
Нам нужно знать ширину экрана, чтобы определить, большой он или маленький. Будем считать экран широким, если его ширина больше 700 px. Чтобы проверить ширину, вызываем функцию getSize(). Она вернет строку, которую мы сохраним в переменной currentSize.
let currentSize = getSize();
Функция buildUI() работает аналогичным образом. Мы проверяем значение currentSize и на его основе строим лейаут для большого или маленького экрана.
currentSize === "small" ? smallUI : bigUI,
$$("top")
);
Добавим обработчик события resize для отслеживания изменения размера окна браузера. Если размер изменился, мы перестраиваем лейаут и синхронизируем данные между компонентами с помощью функции synchronize(). Подробнее об этой функции я расскажу чуть позже.
const newSize = getSize();
if (newSize != currentSize) {
currentSize = newSize;
$$("form").unbind($$("list"));
buildUI();
synchronize();
}
});
Главная функция webix.ui() нашего приложения строит интерфейс на основе значения currentSize.
rows:[
{ template:"Budget Tracker", type:"header", css:"webix_dark" },
currentSize === "small" ? smallUI : bigUI
]
});
synchronize();
Хранение данных
Теперь давайте займемся хранением данных. Для этого мы будем использовать неотображаемый компонент DataCollection, который позволяет хранит коллекцию линейных данных. Нам нужно создать отдельные DataCollection для типов и категорий (types и categories) и получить доступ к ним в компоненте List. А budget будет основным хранилищем данных.
data: [
{ id:1, value: "Income" },
{ id:2, value: "Expense" }
]
});
const categories = new webix.DataCollection({
data: [
{ id: 1, value: "Food" },
{ id: 2, value: "Rent" },
{ id: 3, value: "Shopping" },
… ]
});
Используя событие onStoreUpdated, мы можем отслеживать изменения хранилища данных, а также добавлять новые данные после изменений. В ответ на обновление хранилища наши записи фильтруются в списке и диаграммах на основе значения контролов Radio или Select (в компактном режиме). А с помощью схемы мы можем добавить установку даты новой записи как “сегодня” для тех случаев, когда пользователь не задал ее значение.
data: webix.storage.session.get("money-data") || data,
on: {
"data->onStoreUpdated": function(obj, id, mode) {
if (mode) {
webix.storage.session.put("money-data", this.serialize());
filterByDate(
$$("select")
? $$("select").getValue()
: $$("radio").getValue()
);
}
}
},
scheme: {
$init: (obj) => {
// by default, if the user didn't set the date, let's consider it's today
if (!obj.date) obj.date = new Date();
if (typeof obj.date === "string") obj.date = new Date(obj.date);
},
},
});
Создаем Multiview
Теперь давайте займемся компонентом multiview, который будет состоять из трех ячеек: первая — для формы и списка, вторая и третья — для диаграмм. Для удобства мы сохраним его в отдельную переменную, которую будем использовать в лейауте.
cells: [
{
id: "formView",
rows: [list, form],
},
chartIncome,
chartExpenses,
],
};
Создаем форму
Для создания формы нам понадобятся следующие компоненты Webix:
- Datepicker для выбора даты
- два контрола Select для типов и категорий
- Text для ввода значения дохода\расхода
- кнопки «Clear» и «Save».
Чтобы предотвратить выбор пользователем даты из будущего, мы задаем свойству maxDate значение new Date(). Каждое поле формы должно быть заполнено, поэтому свойство required у каждого компонента должно быть true. Среди прочих условий значение дохода\расхода должно быть числом. У Webix есть набор предопределенных правил для валидации, доступ к которым можно получить через класс webix.rules. Чтобы проверить, что пользователь ввел число, мы можем использовать предопределенное правило webix.rules.isNumber. Кроме того, давайте добавим уведомления для пользователей о неправильно заполненных полях.
view: "form",
id: "form",
elements: [
{
view: "datepicker",
name: "date",
label: "Date",
suggest: {
type: "calendar",
body: {
maxDate: new Date(),
},
},
},
{
view: "select",
name: "type",
label: "Type",
options: types,
required: true,
invalidMessage: "Please, select an option",
},
{
view: "select",
name: "category",
label: "Category",
options: categories,
required: true,
invalidMessage: "Please, select an option",
},
{
view: "text",
label: "Value",
name: "value",
validate: webix.rules.isNumber,
type:"number",
required: true,
invalidMessage: "Please, enter a number",
},
{
cols: [
{
view: "button",
label: "Clear",
click: () => $$("list").unselect(),
},
{
view: "button",
label: "Save",
click: saveItem,
css: "webix_primary",
},
],
},
],
};
Для того чтобы добавить элемент в список, мы создадим функцию addItem() и будем вызывать ее при клике по кнопке «Save». Если все валидации пройдены, то новая запись добавится в DataCollection, а форма очистится. А если нам нужно обновить элемент, то эта функция снова проверит валидацию и обновит DataCollection.
const form = $$("form");
if (form.validate()) {
const values = form.getValues();
if (values.id) budget.updateItem(values.id, values);
else {
const id = budget.add(values, 0);
$$("list").select(id);
}
}
}
Создаем список
Теперь нам нужно отобразить список расходов и доходов. В зависимости от типа каждая запись получит CSS-класс с определенными цветами. Чтобы реализовать это, нам нужно переопределить стандартный компонент List. Webix предоставляет возможность изменить внешний вид компонента через свойство type путем изменения HTML-разметки для элементов.
Сначала давайте настроим свойство templateStart, в который оборачиваются элементы. У него есть свойство webix_l_id, значением которого является obj.id. С помощью этого свойства мы можем найти элемент по его ID. Также здесь элементу присваивается CSS-класс. Далее идет сам темплейт, который содержит необходимую HTML-разметку. И в конце мы разместим templateEnd — завершающий элемент для templateStart.
view: "list",
id: "list",
type: {
templateStart: (obj, common, marks) => {
const css = types.getItem(obj.type).value === "Expense" ? "expense" : "income";
return `<div webix_l_id='${obj.id}' class='${common.classname(obj, common, marks)} custom_item ${css}'>`;
},
template: obj => {
return `<div class='flex category'>${categories.getItem(obj.category).value}
<div class='delete_icon webix_icon mdi mdi-delete'></div>
</div>
<div class='flex'>
<span class='date'>${myformat(obj.date)}</span>
<span class='value'>${obj.value}$</span>
</div>`;
},
templateEnd: "</div>"
},
onClick: {
delete_icon: (e, id) => removeItem(id)
}
};
Чтобы удалять элементы, нам нужно подписаться на событие onClick. После того как пользователь подтвердит действие, запись будет удалена из коллекции.
webix
.confirm({
title: "Entry will be deleted permanently",
text: "Do you still want to continue?",
type: "confirm-warning",
})
.then(() => {
budget.remove(id);
});
return false;
}
Создаем диаграммы
А теперь пришло время создать две диаграммы, которые будут отображаться по одному и тому же шаблону. Давайте создадим функцию-конструктор, которая будет принимать id и значение цветовой схемы. В результате она вернет готовую диаграмму.
return {
view: "chart",
type: "pie",
id,
value: "#money#",
label: obj => categories.getItem(obj.category).value,
pieInnerText: "#money#",
shadow: 0,
color: (item, i) => palette[i],
};
};
Чтобы получить градиентную цветовую палитру для каждой диаграммы, используем функцию getColorPalette(). Она принимает базовый цвет диаграммы и количество представленных в ней категорий. Чтобы работать с цветом как с числом, преобразуем значение цвета в RGB. Для контраста каждая последующая категория получит более светлый цвет, чем предыдущая. Далее мы заполняем массив colors оттенками и конвертируем их значения RGB обратно в HEX. В конечном итоге функция возвращает массив оттенков заданного цвета.
const colors = [baseColor];
if(count>1){
const c = webix.color;
const rgb = c.toRgb(baseColor);
const hsv = c.rgbToHsv(rgb[0], rgb[1], rgb[2]);
const f = hsv[1] * 0.8 / (count - 1);
for(let i = 1; i < count; i++){
const rgb = "rgb(" + c.hsvToRgb(hsv[0], hsv[1] - f * i * 2, hsv[2]) +")";
colors.push("#" + c.rgbToHex(rgb));
}
}
return colors;
}
Количество категорий хранится в переменной categoriesNumber.
После этого мы вызываем функцию getColorPalette() с выбранным базовым цветом.
const paletteE = getColorPalette("#FCBA2E", categoriesNumber);
В завершении этой части вызываем функцию getChart() и передаем ей нужную диаграмму и готовую палитру.
const chartExpenses = getChart("expenseView", paletteE);
Фильтруем данные
Наши пользователи смогут фильтровать данные по времени. Для каждого фильтра мы напишем специальную функцию. С помощью встроенных методов Webix, таких как getMonth() и getFullYear(), мы сможем получить текущий месяц и год соответственно.
const now = new Date();
return now.getMonth() === date.getMonth() && now.getFullYear() === date.getFullYear();
}
function isThisYear(date) {
const now = new Date();
return now.getFullYear() === date.getFullYear();
}
Для вычитания двенадцати месяцев из текущей даты внутри функции isLast12Months() используется метод webix.Date.add().
const now = webix.Date.dayStart(new Date());
const end = webix.Date.add(now, 1, "day", true);
const start = webix.Date.add(end, -12, "month", true);
return start <= date && date < end;
}
Чтобы переключить фильтр, используем четыре переключателя Radio или Select (в компактном режиме).
view: "select",
id: "select",
label: "Show",
value: 1,
options: options,
on: {
onChange: (v) => filterByDate(v),
},
};
const radio = {
id: "radio",
view: "radio",
label: "Show",
css: "chart_radio",
options: options,
on: {
onChange: (v) => filterByDate(v),
},
};
В зависимости от значения Radio\Select применяем нужную функцию для фильтрации.
let filterer;
switch (value) {
case "1":
filterer = isThisMonth;
break;
case "2":
filterer = isThisYear;
break;
case "3":
filterer = isLast12Months;
break;
case "4":
filterer = null;
break;
}
budget.filter(filterer);
}
Функция prepareChartData() используется диаграммами для фильтрации значений по типу (расход или доход). После этого данные распределяются по категориям, и значения для каждой категории суммируются.
this.filter("type", type);
this.group({
by: "category",
map: {
money: ["value", "sum"],
},
});
}
Синхронизируем компоненты
Для синхронизации данных между компонентами мы будем использовать метод sync(). Синхронизация позволяет копировать данные из DataStore и отображать их в виде списка. Любые изменения, внесенные в DataStore (например, добавление, удаление и т.д.), будут сразу отражены в списке и диаграммах.
Чтобы отфильтровать данные графика перед их отображением, мы передаем колбэк-функцию prepareChartData() в качестве второго параметра методу sync().
Если компонент Radio существует, устанавливаем его значение равным “1”. В самом конце, чтобы заполнить форму данными из выбранной записи в списке, мы связываем их между собой.
const list = $$("list");
list.sync(budget);
$$("incomeView").sync(budget, function () {
prepareChartData.call(this, 1);
});
$$("expenseView").sync(budget, function () {
prepareChartData.call(this, 2);
});
if ($$("radio")) $$("radio").setValue(1);
$$("form").bind(list);
}
Вот и все! Мы настроили все компоненты, связали их между собой и соединили с несколькими хранилищами данными. Теперь пользователи могут записывать свои доходы и расходы, фильтровать их по дате и анализировать финансовые данные с помощью диаграмм.
Заключение
С кодом готового приложения и живой демкой можно ознакомиться здесь.
В этой статье мы подробно разобрали как создать приложение для учета финансов с помощью компонентов GPL-версии Webix.
Если у вас возникли вопросы, поделитесь ими в комментариях ниже. Чтобы узнать больше обо всех возможностях виджетов Webix с соответствующими примерами, посетите документацию библиотеки.