Lori Timesheets — учет времени на платформе CUBA
«Время — это капитал работника умственного труда.»
Оноре де Бальзак
Часто случается, что люди отдают предпочтение старым и привычным вещам, игнорируя новые, даже себе во вред. Вот так и мы долгое время с упорством использовали систему учета времени, которая не отвечала нашим требованиям и постоянно создавала проблемы буквально всем — от программистов до бухгалтерии.
Всеобщие мучения с системой учета времени, по причине отсутствия времени (см рисунок), не стали веским основанием для разработки своей системы. Спасла же ситуацию идея написать реальное приложение для демонстрации возможностей нашей платформы CUBA. Совмещая приятное с полезным, система учета времени стала первым кандидатом.
В настоящий момент разработка завершена, приложение внедрено в нашей компании, и мы готовы поделиться им со всеми желающими.
В этой статье я расскажу, как мы в сжатые сроки (< 1 мес), ограниченными силами (человек и еще полчеловека) разработали это приложение.
По сложившейся практике, приступая к разработке системы, мы описываем предметную область, определяя сущности, которые будут фигурировать в системе. Такой подход позволяет заранее оценить (хоть и не точно) предполагаемую сложность будущей системы.
Давайте подумаем, какие сущности нам понадобятся в системе учета времени.
- Пользователь — понятно, куда же без пользователя. К счастью, такая сущность уже есть в платформе, используем ее.
- Клиент — тот кто платит деньги.
- Проект — за что платят деньги.
- Задача — конкретный вид работ.
- Запись времени — сколько времени пользователь потратил на выполнение определенной задачи в конкретный день.
Казалось бы, этого достаточно. Именно такой набор сущностей мы имели в старой системе. Для своего удобства нам пришлось кое-что добавить.
- Роль на проекте — проектов много, один и тот же пользователь может быть менеджером одного проекта и разработчиком на другом (у нас так бывает).
- Участник проекта — связь проекта и пользователя, содержит ссылки на них и роль на данном проекте.
- Тип задачи — признак, по которому можно объединить несколько задач. Например тип задач «Тестирование» позволит нам понять, сколько мы времени тратим на тестирование в рамках компании, и сравнить этот показатель для разных проектов.
- Тип активности — признак по которому можно объединить несколько записей времени. Например в рамках задачи по разработке мобильного приложения мы делаем анализ, пишем код, пишем тесты, фиксим баги. При необходимости, добавив в проект соответствующие типы активности, мы сможем отслеживать, сколько времени было затрачено по каждому пункту.
- Нерабочие дни — чтобы люди не ошибались, заполняя таймшиты на праздничные дни, мы подсвечиваем такие дни в системе используя заданные нерабочие дни.
- Теги — слабоструктурированные метки, которые можно добавлять в записи времени.
- Типы тегов — признак, по которому группируются определенные теги.
Свойства каждой сущности мы в статье описывать не будем, их вы можете увидеть сами, заглянув в код проекта.
Определившись с объектной моделью, мы начали работу в CUBA-студии. Для начала мы создали соответствующие сущности. После этого мы воспользовались замечательной возможностью автогенерации кода в студии, получив SQL скрипты для создания базы данных, а также стандартные экраны (экран списка сущностей и экран редактирования отдельной сущности) с CRUD-действиями. Для 80% сущностей оказалось достаточно стандартных экранов. Те экраны, которые нуждались в доработке, мы редактировали с помощью WYSIWYG редактора экранов.
Затем мы настроили главное меню приложения, чтобы пользователи имели удобный доступ к необходимым сущностям.
В тот момент приложение уже можно было запускать и работать с сущностями объектной модели (создавать/редактировать/удалять).
Всего за несколько часов мы создали реально работающий, полнофункциональный прототип системы учета времени.
Мы получили работающий прототип, но он был очень далек от рабочего приложения.
Давайте посмотрим, чего же мы хотели добиться:
- Скорость и удобство заполнения таймшитов
- Простота настройки системы
- Приятный внешний вид
Ввод таймшитов за неделю
Первое, что нам было нужно — это удобное заполнение таймшитов за неделю. В старой системе был такой экран и мы решили сделать похожий. Вот что у нас получилось.
Как вы возможно помните, в объектной модели нет сущности «Отчет за неделю». Можно было бы реализовать данный экран вообще без каких-либо привязок к сущностям, однако гораздо удобнее создать промежуточную неперсистентную сущность, которая не связана с базой данных напрямую, но весь набор компонентов работает с ней как с обычной сущностью. Такой прием позволяет абстрагироваться от структуры базы данных и создавать экраны, более понятные пользователю. Выглядит такая сущность очень просто:
@MetaClass(name = "ts$WeeklyReportEntry")
public class WeeklyReportEntry extends AbstractNotPersistentEntity {
.....
@MetaProperty(mandatory = true)
protected Project project;
@MetaProperty(mandatory = true)
protected Task task;
.....
}
В данном случае WeeklyReportEntry представляет собой 1 строку в таблице.
Таблицу мы сделали редактируемой, чтобы было удобнее заполнять часы на каждый день недели. Кроме этого, мы добавили подсчет суммы часов в столбцах, а также подсветку потенциально неверно заполненных дней. Потом, по просьбе пользователей мы добавили возможность группировать записи в таблице по проекту и по задаче. Все это было сделано с помощью стандартных механизмов платформы CUBA.
Ввод таймшитов с календаря
Следующим по важности для нас был экран ввода таймшитов с календаря. К сожалению, на данный момент в платформе нет компонента «Календарь». Однако, он есть во фреймворке Vaadin, который мы используем для отрисовки веб-клиента. Унаследовав его и слегка доработав (описано ниже), мы использовали его в своем приложении. В календарь мы также добавили валидацию потенциально неверно заполненных таймшитов, подсветку праздников и выходных дней, суммирование часов по неделям и месяцу.
Настройка проектов и задач
Еще одной целью для нас была простота настройки проектов и задач. Несмотря на то, что у нас были базовые экраны проекта и задачи, мы решили сделать специальный экран, который сделал бы настойку легкой и приятной. Ключевыми требованиями были: возможность быстро переключаться между проектами, возможность быстро добавлять людей и задачи в разные проекты. Было принято решение сделать экран в виде 3-х связанных таблиц: проекты, задачи и участники проектов. При выборе проекта в таблицах задач и участников показываются записи, соответствующие этому проекту. Платформа CUBA позволяет создавать связанные источники данных для таблиц, поэтому никакого специального кода для этого не потребовалось. Фактически, из 3-х стандартных экранов (список проектов, список задач, список участников проектов) мы собрали один, заменивший все 3.
«Командная строка»
Еще одним новшеством, которое мы решили реализовать, стала так называемая командная строка. Она позволяет с помощью ввода простой текстовой команды заполнить таймшиты за неделю и даже за целый месяц. Выглядит это так:
Также, с помощью компонента Vaadin, который называется AceEditor, мы научили командную строку делать подсказки. Об этом мы расскажем ниже.
Следует заметить, что эту замечательную концепцию мы подсмотрели в системе Everhour, слегка доработав ее под свои нужды.
Естественно, все эти экраны не были разработаны нами за один раз.
Как обычно, доведение UI до ума заняло гораздо больше времени, чем создание его первого варианта. Здесь нам сильно помог механизм «горячей загрузки» изменений на сервер, реализованный в платформе CUBA. Благодаря ему около 90%(~) изменений в UI не нуждаются в перезагрузке сервера. Более того, существует возможность перезагружать так логику ядра (сервисы и бины). Более подробно этот механизм описан в нашей статье.
Добавляем календарь
Как уже было сказано выше, нам понадобился календарь. К счастью он был обнаружен в списке компонентов Vaadin. Сейчас я расскажу как мы его добавляли.
Vaadin построен на GWT, но при этом Vaadin-компонент существует как на клиенте, так и на сервере. Обычно существует также промежуточная часть, которая используется для связи клиента и сервера. Таким образом, чтобы расширить календарь, нам придется работать как с серверным, так и с GWT кодом.
У календаря есть состояние com.vaadin.shared.ui.calendar.CalendarState. Мы хотим чтобы состояние хранило, помимо прочего, дни, которые считаются выходными (это настраивается в системе), и праздники. Для этого мы наследуем этот класс.
public class TimeSheetsCalendarState extends CalendarState {
.....
public Set weekends = new HashSet<>();
public Set holidays = new HashSet<>();
....
}
Теперь мы должны унаследовать серверный класс com.vaadin.ui.Calendar, чтобы заполнять новые свойства.
public class TimeSheetsCalendar extends Calendar {
....
public TimeSheetsCalendar(CalendarEventProvider eventProvider) {
super(eventProvider);
getState().weekends = getWeekends();
}
@Override
public void beforeClientResponse(boolean initial) {
super.beforeClientResponse(initial);
getState().holidays = getHolidays();
}
....
}
После этого мы можем унаследовать виджет com.vaadin.client.ui.VCalendar и сделать так, чтобы он менял стиль ячейки в зависимости от того, праздник это или нет.
public class TimeSheetsCalendarWidget extends VCalendar {
protected Set weekends = new HashSet();
protected Set holidays = new HashSet();
protected boolean isWeekend(int dayNumber) {
return weekends.contains(dayNumber);
}
protected boolean isHoliday(String date) {
return holidays.contains(date);
}
@Override
protected void setCellStyle(Date today, List days, String date, SimpleDayCell cell, int columns, int pos) {
CalendarDay day = days.get(pos);
if (isWeekend(day.getDayOfWeek()) || isHoliday(date)) {
cell.addStyleName("holiday");
cell.setTitle(date);
}
}
Осталось только расширить класс com.vaadin.client.ui.calendar.CalendarConnector, чтобы он копировал данные о праздниках и выходных из состояния в виджет.
@Connect(value = TimeSheetsCalendar.class, loadStyle = Connect.LoadStyle.LAZY)
public class TimeSheetsCalendarConnector extends CalendarConnector {
@Override
public TimeSheetsCalendarWidget getWidget() {
return (TimeSheetsCalendarWidget) super.getWidget();
}
@Override
public TimeSheetsCalendarState getState() {
return (TimeSheetsCalendarState) super.getState();
}
@Override
public void onStateChanged(StateChangeEvent stateChangeEvent) {
getWidget().setWeekends(getState().weekends);
getWidget().setHolidays(getState().holidays);
super.onStateChanged(stateChangeEvent);
}
}
В результате мы можем добавить TimeSheetsCalendar в любой экран, созданный на платформе CUBA.
public class CalendarScreen extends AbstractWindow {
@Inject
protected BoxLayout calBox;
protected TimeSheetsCalendar calendar;
....
protected void initCalendar() {
....
calendar = new TimeSheetsCalendar(dataSource);
....
AbstractOrderedLayout calendarLayout = WebComponentsHelper.unwrap(calBox);
calendarLayout.addComponent(calendar);
}
Добавляем подсказки в «командную строку»
Чтобы пользователям было удобно использовать «командную строку», мы решили, что она должна подсказывать варианты ввода.
В Vaadin есть компонент AceEditor, который умеет это делать. Он используется в платформе (WebSourceCodeEditor), чтобы выдавать подсказки в JPQL запросах (например при редактировании запроса в отчете).
Мы решили упростить себе жизнь и вместо написания нового компонента на базе AceEditor расширили WebSourceCodeEditor.
В первую очередь мы расширили org.vaadin.aceeditor.SuggestionExtension, зарегистрировав в нем RPC сервис, который должен обрабатывать применение командной строки.
public class CommandLineSuggestionExtension extends SuggestionExtension {
protected Runnable applyHandler;
public CommandLineSuggestionExtension(Suggester suggester) {
super(suggester);
registerRpc(new CommandLineRpc() {
@Override
public void apply() {
if (applyHandler != null) {
applyHandler.run();
}
}
});
}
public void setApplyHandler(Runnable applyHandler) {
this.applyHandler = applyHandler;
}
public Runnable getApplyHandler() {
return applyHandler;
}
}
Затем пришла очередь платформенного класса com.haulmont.cuba.web.gui.components.WebSourceCodeEditor.
public class WebCommandLine extends WebSourceCodeEditor implements CommandLine {
@Override
public void setSuggester(Suggester suggester) {
this.suggester = suggester;
if (suggester != null && suggestionExtension == null) {
suggestionExtension = new CommandLineSuggestionExtension(new CommandLineSourceCodeEditorSuggester());
suggestionExtension.extend(component);
suggestionExtension.setShowDescriptions(false);
}
}
protected class CommandLineSourceCodeEditorSuggester extends SourceCodeEditorSuggester {
}
public CommandLineSuggestionExtension getSuggestionExtension() {
return (CommandLineSuggestionExtension) suggestionExtension;
}
}
И наконец client-side класс org.vaadin.aceeditor.client.SuggesterConnector.
@Connect(CommandLineSuggestionExtension.class)
public class CommandLineSuggesterConnector extends SuggesterConnector {
protected CommandLineRpc commandLineRpc = RpcProxy.create(
CommandLineRpc.class, this);
@Override
public Command handleKeyboard(JavaScriptObject data, int hashId,
String keyString, int keyCode, GwtAceKeyboardEvent e) {
if (suggesting) {
return keyPressWhileSuggesting(keyCode);
}
if (e == null) {
return Command.DEFAULT;
}
if (keyCode == 13) {//Enter
commandLineRpc.apply();
return Command.NULL;//ignore enter
} else if ((keyCode == 32 && e.isCtrlKey())) {//Ctrl+Space
startSuggesting();
return Command.NULL;
} else if ((keyCode == 50 && e.isShiftKey())//@
|| (keyCode == 51 && e.isShiftKey())//#
|| (keyCode == 52 && e.isShiftKey())//$
|| (keyCode == 56 && e.isShiftKey())) {//*
startSuggestingOnNextSelectionChange = true;
widget.addSelectionChangeListener(this);
return Command.DEFAULT;
}
return Command.DEFAULT;
}
}
В нем мы переопределили поведение редактора — подсказки должны появляться кроме Ctrl-Space при нажатии @,#,$,* (подсказка проектов, задач, тегов, типов активности). Нажатие Enter должно применять командную строку (заполнить таймшиты).
Как вы возможно помните, мы решили использовать класс User, предоставляемый платформой. Нам хотелось, чтобы в записи пользователя можно было хранить количество обязательных рабочих часов в неделю. Это нужно для валидации введенных данных (если человек указал в таймшитах больше или меньше чем должен). У нас был выбор — создать новую сущность, которая бы ссылалась на систменого пользователя, или расширить платформенную сущность. В целях экономии усилий мы решили пойти путем расширения, потому что этот механизм довольно прост (с точки зрения использования) и отлично работает. Сейчас я покажу, как мы реализовали данное расширение.
Во-первых, нужно было сделать наследника класса com.haulmont.cuba.security.entity.User и добавить туда новое поле.
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorValue("Ext")
@Entity(name = "ts$ExtUser")
@Extends(User.class)
public class ExtUser extends User {
....
@Column(name = "WORK_HOURS_FOR_WEEK", nullable = false)
protected BigDecimal workHoursForWeek;
public BigDecimal getWorkHoursForWeek() {
return workHoursForWeek;
}
public void setWorkHoursForWeek(BigDecimal workHoursForWeek) {
this.workHoursForWeek = workHoursForWeek;
}
....
}
Затем мы создали экран, расширяющий экран редактирования пользователя и зарегистрировали его в системе.
Теперь вместо сущности User в системе присутствует ExtUser и экран редактирования содержит поле workHoursForWeek.
Это самый простой пример расширения функциональности, если вы хотите узнать о расширениях больше — прочитайте нашу статью.
С самого начала мы планировали сделать данную систему продуктом, которым будут пользоваться другие люди. Для того, чтобы установка и запуск системы были простыми, мы решили сделать что-то вроде дистрибутива.
Наш «дистрибутив» представляет собой zip-файл, который содержит папку c контейнером сервлетов Tomcat и скриптами для запуска/остановки системы.
Так как для сборки проектов на платформе используется Gradle, собирать такой «дистрибутив» не представляет труда.
def distribDir="./distrib"
def scriptsDir="./scripts"
task cleanTomcatLogs << {
def dir = new File(tomcatDir, '/logs/')
if (dir.isDirectory()) {
ant.delete(includeemptydirs: true) {
fileset(dir: dir, includes: '**/*')
}
}
}
task copyTomcat(type: Copy, dependsOn: ['setupTomcat',':app-core:deploy', ':app-web:deploy', ':app-web-toolkit:deploy', 'cleanTomcatLogs']) {
from file("$tomcatDir/..")
include "tomcat/**"
into "$distribDir"
}
task copyLoriScripts(type: Copy) {
from file("$scriptsDir")
include "*lori.*"
into "$distribDir"
}
task copyTomcatScripts(type: Copy, dependsOn: 'copyTomcat') {
from file("$scriptsDir")
include "*classpath.*"
into "$distribDir/tomcat/bin/"
}
task buildDistributionZip(type: Zip, dependsOn: ['copyLoriScripts', 'copyTomcatScripts']) {
from "$distribDir"
exclude "*.zip"
baseName = 'lori'
version= "$artifactVersion"
destinationDir = file("$distribDir")
}
task distribution(dependsOn: buildDistributionZip) << {
}
Единственная проблема возникла с Tomcat. Он отчаянно не хотел стартовать в системе, где не задана системная переменная JAVA_HOME.
Чтобы заставить его игнорировать отсутствие этой переменной, пришлось заменить скрипты setclasspath.sh и setclasspath.bat на более простые.
Продуктов для учета рабочего времени достаточно много, поэтому наверное возникает вопрос, зачем мы написали еще один? Причин несколько. Основная — мы хотели, чтобы продукт был максимально удобен именно в нашей сфере деятельности (разработка ПО). Кроме того, нам необходимо было обеспечить простоту интеграции с другими системами и доработки под меняющиеся процессы. Ну, и наконец, мы хотели создать полезное приложение, которое стало бы хорошим примером разработки на платформе CUBA.
Само приложение бесплатно, его код доступен на github. Встроенная лицензия на платформу CUBA позволяет одновременно работать 5 пользователям. Пожизненная лицензия для неограниченного числа пользователей стоит символические $10.
Мы надеемся, что Lori Timesheets принесет пользу не только нам, но и кому-то еще. Открытый код и механизм расширений позволят легко адаптировать приложение под себя.