Создаем приложение Budget Tracker App c Webix UI

Глядя на свой банковский счет в конце месяца, каждый из нас хоть раз задавался вопросом: “А куда ушли все мои деньги?”. Отслеживание расходов — это непростая задача. Иногда бывают незапланированные траты на дружеские посиделки или спонтанные покупки. Хорошим решением будет сохранять чеки, чтобы видеть общую картину своих расходов. Здесь на помощь приходят приложения для учета финансов. Подобные инструменты позволяют легко проанализировать, сколько вы тратите на определенные категории: еда, транспорт, подарки, уход за домашними питомцами и т.д.

И сегодня я покажу вам, как создать подобное приложение для учета финансов с помощью компонентов Webix.

С кодом готового приложения и живой демкой можно ознакомиться здесь.

Обзор приложения

Основу приложения будет составлять список доходов и расходов. У каждой записи из списка будет тип (доход или расход), категория, дата и значение. Пользователи смогут отфильтровать данные по датам с помощью фильтров «This month», «This year», «Last 12 months» и «All». Наглядно показывать суммы по категориям будут две диаграммы — «Expenses» и «Income».

Для сбора пользовательских данных мы будем использовать виджет Form, для представления записей — List. Визуализацию данных мы реализуем с помощью виджета Chart.

У нашего приложения будет два режима отображения: полноэкранный и компактный. В полноэкранном режиме все виджеты будут находиться на одной странице. В компактном вы сможете переключаться между тремя вкладками: «Form», «Expense» и «Income». Для этого мы воспользуемся компонентом Multiview.

В браузере наше приложение будет выглядеть так:

    • полноэкранный режим

полноэкранный режим

    • компактный режим

компактный режим

Подготавливаем почву

Для начала давайте подключим библиотеку Webix. Для этого приложения нам подойдет стандартная GPL-версия. Она доступна для загрузки здесь или через CDN по следующим ссылкам:

<script type="text/javascript" src="https://cdn.webix.com/edge/webix.js"></script>

Теперь давайте рассмотрим, как создать необходимые компоненты интерфейса.

Создаем лейаут

Как я уже говорила, у приложения будет два лейаута: один для полноэкранного режима, другой для компактного. Для хранения каждого из них определим переменные smallUI и bigUI. Чтобы сделать интерфейс более понятным, все виджеты тоже будем хранить в отдельных переменных.

const smallUI = {
    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.

const getSize = () => document.body.offsetWidth > 700 ? "wide" : "small";
let currentSize = getSize();

Функция buildUI() работает аналогичным образом. Мы проверяем значение currentSize и на его основе строим лейаут для большого или маленького экрана.

const buildUI = () => webix.ui(
  currentSize === "small" ? smallUI : bigUI,
  $$("top")
);

Добавим обработчик события resize для отслеживания изменения размера окна браузера. Если размер изменился, мы перестраиваем лейаут и синхронизируем данные между компонентами с помощью функции synchronize(). Подробнее об этой функции я расскажу чуть позже.

webix.event(window, "resize", function() {
  const newSize = getSize();
  if (newSize != currentSize) {
    currentSize = newSize;
    $$("form").unbind($$("list"));
    buildUI();
    synchronize();
  }
});

Главная функция webix.ui() нашего приложения строит интерфейс на основе значения currentSize.

webix.ui({
  rows:[
    { template:"Budget Tracker", type:"header", css:"webix_dark" },
    currentSize === "small" ? smallUI : bigUI
  ]
});

synchronize();

Хранение данных

Теперь давайте займемся хранением данных. Для этого мы будем использовать неотображаемый компонент DataCollection, который позволяет хранит коллекцию линейных данных. Нам нужно создать отдельные DataCollection для типов и категорий (types и categories) и получить доступ к ним в компоненте List. А budget будет основным хранилищем данных.

const types = new webix.DataCollection({
  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 (в компактном режиме). А с помощью схемы мы можем добавить установку даты новой записи как “сегодня” для тех случаев, когда пользователь не задал ее значение.

const budget = new webix.DataCollection({
  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, который будет состоять из трех ячеек: первая — для формы и списка, вторая и третья — для диаграмм. Для удобства мы сохраним его в отдельную переменную, которую будем использовать в лейауте.

const 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. Кроме того, давайте добавим уведомления для пользователей о неправильно заполненных полях.

const form = {
  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.

function saveItem() {
  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.

const list = {
  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. После того как пользователь подтвердит действие, запись будет удалена из коллекции.

function removeItem(id) {
      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 и значение цветовой схемы. В результате она вернет готовую диаграмму.

const getChart = (id, palette) => {
  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. В конечном итоге функция возвращает массив оттенков заданного цвета.

function getColorPalette(baseColor, count){
    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.

const categoriesNumber = categories.count();

После этого мы вызываем функцию getColorPalette() с выбранным базовым цветом.

const paletteI = getColorPalette("#8b55e7", categoriesNumber);
const paletteE = getColorPalette("#FCBA2E", categoriesNumber);

В завершении этой части вызываем функцию getChart() и передаем ей нужную диаграмму и готовую палитру.

const chartIncome = getChart("incomeView", paletteI);
const chartExpenses = getChart("expenseView", paletteE);

Фильтруем данные

Наши пользователи смогут фильтровать данные по времени. Для каждого фильтра мы напишем специальную функцию. С помощью встроенных методов Webix, таких как getMonth() и getFullYear(), мы сможем получить текущий месяц и год соответственно.

function isThisMonth(date) {
  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().

function isLast12Months(date) {
  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 (в компактном режиме).

const 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 применяем нужную функцию для фильтрации.

function filterByDate(value) {
  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() используется диаграммами для фильтрации значений по типу (расход или доход). После этого данные распределяются по категориям, и значения для каждой категории суммируются.

function prepareChartData(type) {
  this.filter("type", type);
  this.group({
    by: "category",
    map: {
      money: ["value", "sum"],
    },
  });
}

Синхронизируем компоненты

Для синхронизации данных между компонентами мы будем использовать метод sync(). Синхронизация позволяет копировать данные из DataStore и отображать их в виде списка. Любые изменения, внесенные в DataStore (например, добавление, удаление и т.д.), будут сразу отражены в списке и диаграммах.

Чтобы отфильтровать данные графика перед их отображением, мы передаем колбэк-функцию prepareChartData() в качестве второго параметра методу sync().

Если компонент Radio существует, устанавливаем его значение равным “1”. В самом конце, чтобы заполнить форму данными из выбранной записи в списке, мы связываем их между собой.

function synchronize() {
  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 с соответствующими примерами, посетите документацию библиотеки.