From 079f7110d52a7e0c09c3631275c1bda9e8407c15 Mon Sep 17 00:00:00 2001 From: Vyatcheslav Suharnikov Date: Tue, 20 Jan 2015 21:58:22 +0300 Subject: [PATCH] Init --- .gitignore | 4 + Gruntfile.js | 19 ++ README.md | 83 +++++++ package.json | 15 ++ src/main.js | 46 ++++ src/reports-general.js | 117 +++++++++ src/reports-monthly.js | 513 +++++++++++++++++++++++++++++++++++++++ src/utils.js | 238 ++++++++++++++++++ tools/tasks/gs-export.js | 71 ++++++ tools/tasks/gs-import.js | 65 +++++ 10 files changed, 1171 insertions(+) create mode 100644 .gitignore create mode 100644 Gruntfile.js create mode 100644 README.md create mode 100644 package.json create mode 100644 src/main.js create mode 100644 src/reports-general.js create mode 100644 src/reports-monthly.js create mode 100644 src/utils.js create mode 100644 tools/tasks/gs-export.js create mode 100644 tools/tasks/gs-import.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dc2589 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules + +*.i* + diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..dd91662 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,19 @@ +module.exports = function (grunt) { + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + // + 'gs-import': { + options: { + destDir: 'src', + importFilePath: 'dist/graffity.json' + } + }, + 'gs-export': { + options: { + srcDir: 'src', + exportDir: 'dist', + filePrefix: 'graffity' + } + } + }); +}; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e1b203 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +Отчеты о выполненной работе в _Google Drive_. + +Примеры генерации ежемесячных отчетов c учетом праздников и предпраздничных дней. + +Создание проекта +================ + +1. Необходимо удостовериться, установлен ли _Google Apps Script_ в вашем _Google Drive_. + + 1. Заходим на главную страницу _Google Drive_; + 2. Жмем _Создать_. Если пункта _Скрипт_ нет, значит не установлен; + 3. Для установки жмем "Подключить другие приложения", вводим в поиск "Google Apps Script" и жмем _Подключить_; + +2. Жмем _Создать_, выбираем _Скрипт_. +3. К сожалению, импорта по аналогии с экспортом (в формате _JSON_) нет. + Поэтому придется вручную создавать файлы с помощью. _Файл_ → _Создать_ → _Скрипт_. +4. Создаем скрипты аналогичные тем, что в папке _src_ (обратите внимание, что расширение будет _.gs_) и копируем в них содержимое. +5. Сохраняем проект (Название не важно). + +Получение ключа проекта +======================= + +1. Открываем проект; +2. В меню: _Файл_ → _Свойства проекта_; +3. В первой вкладке ("Информация"): _Project key_. + +Установка +========= + +Установка выполняется единожды для каждого отчета. + +Действия администоратора +------------------------ + +Администратор выполняет основную работу. +Для выполнения установки ему необходимо получить ключ проекта (см. выше). + +1. Открываем документ с отчетами пользователя (документ _Google Spreadsheets_); +2. В меню: _Инструменты_ → _Редактор скриптов_. Откроется новый документ и появится окно. Жмем на кнопку _Закрыть_; +3. В меню: _Ресурсы_ → _Библиотеки_. Появится диалог с предложением переименовать проект. + + 1. Вводим какое-нибудь осмысленное название, например, _Отчеты Филлипа Дж. Фрая_; + 2. Жмем _Ok_; + +4. В "Поиск библиотеки" вбиваем ключ проекта (_Project key_), жмем _Выбрать_. Найдется библиотека. Далее: + + 1. Выбираем самую последнюю версию; + 2. В идентификаторе должно быть написано _googleReports_ (именно такое название, см. ниже); + 3. Жмем _Режим разработки_ (чтобы всегда загружался последний код); + 4. Жмем _Сохранить_. + +5. Заменяем код исходника на: + + ``` + function onOpen() { + googleReports.Register({ + menuTitle: 'Отчеты', + importName: 'googleReports' + }); + } + ``` + +6. Жмем _Сохранить_. + +Действия пользователя +--------------------- + +Выполняется единожды. + +1. Открываем свой отчет; +2. В меню: _Инструменты_ → _Редактор скриптов_. Откроется редактор скриптов. +3. Жмем _Выполнить_ (треугольник рядом с жуком). Потребуется авторизация для скрипта. + + 1. Жмем _Продолжить_; + 2. Жмем _Принять_. + +4. В документе _Google Spreadsheet_ появится новый пункт меню. +5. При повторном открытии документа выполнять данные действия не понадобится, пункт в меню появится автоматически. + +Обновление +========== + +Просто сохраняем изменения в скрипте. Пользователям потребуется лишь обновить страницу. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..191ed10 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "google-reports", + "version": "0.0.1", + "description": "Srcipts for generating reports at Google Spreadsheets", + "main": "Gruntfile.js", + "keywords": [ + "google", + "reports" + ], + "author": "Vyatcheslav Suharnikov", + "license": "MIT", + "devDependencies": { + "grunt": "^0.4.5" + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..1059899 --- /dev/null +++ b/src/main.js @@ -0,0 +1,46 @@ +/** + * Регистрация пунктов меню в текущей таблице. + * + * @param {Object} options Настройки. + * + * @option options {string} menuTitle Заголовок в меню. + * @option options {string} importName Наименование библиотеки, указанное при подключении. + */ +function Register(options) { + reports.general.ensureReport(); + + // Почему функции находятся в "reports."? + // Потому что это - глобальный объект (указывается при настройке), в который загружаются скрипты из проекта. + SpreadsheetApp.getUi() + .createMenu(options.menuTitle) + .addItem('Общий - Скрыть дни', options.importName + '.General_hideDays') + .addItem('Общий - Скрыть минуты', options.importName + '.General_hideMinutes') + .addSeparator() + .addItem('Ежемесячный - Текущий', options.importName + '.Monthly_createForCurrent') + .addItem('Ежемесячный - Следующий', options.importName + '.Monthly_createForNext') + .addToUi(); +} + +// В виду ограничений функции addItem. + +function General_hideMinutes() { + reports.general.hideMinutes(); +} + +function General_hideDays() { + reports.general.hideDays(); +} + +function Monthly_createForCurrent() { + var report = new reports.Monthly(reports.Utils.getCurrentMonthFirstDayDate()); + report.makeEmptyReport(); + + reports.general.ensureFirst(); +} + +function Monthly_createForNext() { + var report = new reports.Monthly(reports.Utils.getNextMonthFirstDayDate()); + report.makeEmptyReport(); + + reports.general.ensureFirst(); +} \ No newline at end of file diff --git a/src/reports-general.js b/src/reports-general.js new file mode 100644 index 0000000..8670e2f --- /dev/null +++ b/src/reports-general.js @@ -0,0 +1,117 @@ +// Генерация главного отчета. +// Главный отчет содержит данные за все месяцы в компактном виде. + +// В пространстве имен reports. +if (typeof reports === 'undefined') { + reports = {}; +} + +/** + * @param {Object} options Настройки. + * + * @option options {String} title Наименование заголовка. + * + * @constructor + * @author Vyatcheslav Suharnikov + */ +reports.General = function (options) { + if (!options) { + options = {}; + } + + this._title = options.title || 'Отчет'; // Наименование для листа с отчетом. + this._sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(this._title); +}; + +reports.General.prototype = { + ensureFirst: function () { + reports.Utils.ensureSheetIndex(this._sheet, 0); + }, + + hideDays: function () { + var sheet = this._sheet; + + // getMaxRows + }, + + hideMinutes: function () { + var sheet = this._sheet; + + sheet.hideColumn(sheet.getRange(2, 6, sheet.getMaxRows() - 1, 1)); + }, + + ensureReport: function () { + if (!this._sheet) { + this._sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(this._title, 0); + this._prepareSheet(); // delete this + } + + this.hideDays(); + this.ensureFirst(); + }, + + _prepareSheet: function () { + this._createHeader(1, 1); + }, + + _createHeader: function (startRow, startColumn) { + var sheet = this._sheet, + columnWidths = [ + 75, // Дата. + 120, // Проект. + 460, // Задача. + 65, // Начало. + 65, // Конец. + 65, // Минут. + 580 // Комментарий. + ], + columnCount = columnWidths.length; + + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) { + sheet.setColumnWidth(startColumn + columnIndex, columnWidths[columnIndex]); + } + + sheet.getRange(startRow, startColumn, 1, columnCount) + .setValues([ + ['Дата', 'Проект', 'Задача', 'Начало', 'Конец', 'Минут', 'Комментарий'] + ]) + .setFontWeight('bold') + .setHorizontalAlignment('center') + .setBackground('#969696') + .setFontColor('#fff') + .setFontSize(12); + + sheet.setFrozenRows(startRow); + + // Задаем форматирование ячеек. + var lastRow = sheet.getMaxRows() - startRow; + + // Дата. + sheet.getRange(startRow + 1, startColumn, lastRow, 1) + .setNumberFormat('dd.MM.YYYY') + .setHorizontalAlignment('center'); + + // Проект. + sheet.getRange(startRow + 1, startColumn + 1, lastRow, 1) + .setHorizontalAlignment('center'); + + // Начало, Конец. + sheet.getRange(startRow + 1, startColumn + 3, lastRow, 2) + .setNumberFormat('00:00') + .setHorizontalAlignment('center'); + + // Формулы. + sheet.getRange(startRow + 1, startColumn + 5, lastRow, 1) + .setFormulaR1C1('=HOUR(R[0]C[-1]-R[0]C[-2])*60 + MINUTE(R[0]C[-1]-R[0]C[-2])'); + + // Скрываем столбец с минутами. + this.hideMinutes(); + + // Удаляем лишние столбцы. + if (sheet.getMaxColumns() - columnCount > 0) { + sheet.deleteColumns(startColumn + columnCount, sheet.getMaxColumns() - columnCount); + } + } +}; + +reports.general = new reports.General(); \ No newline at end of file diff --git a/src/reports-monthly.js b/src/reports-monthly.js new file mode 100644 index 0000000..6334df1 --- /dev/null +++ b/src/reports-monthly.js @@ -0,0 +1,513 @@ +// Генерация ежемесячного отчета. +// В пространстве имен reports. +if (typeof reports === 'undefined') { + reports = {}; +} + +/** + * @param {Date} date Дата, для которой создается отчет. + * + * @constructor + * @author Vyatcheslav Suharnikov + */ +reports.Monthly = function (date) { + this._date = date; + this._daysInMonth = reports.Utils.getDaysInMonth(date); + this._reportName = reports.Utils.formatDateMMMMY(date); // Наименование для листа с отчетом. + this._sheet = null; +}; + +reports.Monthly.prototype = { + /** + * Создание пустого отчета для месяца, указанного в дате. + * + * Отчет создается в новой вкладке на первом месте. + * Если отчет уже создан - появляется предупреждение и осуществляется переход к отчету. + */ + makeEmptyReport: function () { + var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(), // Текущий документ. + sheet = spreadsheet.getSheetByName(this._reportName); + + if (sheet) { + // Уже готов. + sheet.activate(); + SpreadsheetApp.getUi().alert('Отчет "' + this._reportName + '" уже создан.'); + return; + } + + this._sheet = spreadsheet.insertSheet(this._reportName, 0); + + this._initialize(); + + this._setupHeader(1, 1); + this._setupDayRows(4, 1); // Первые 3 строки для заголовка. + this._setupSummaryRow(4 + this._daysInMonth, 1); + this._setupFooter(4 + this._daysInMonth + 1, 1); + this._setupLegend(1, 18); + + // Формула для проверки. + var summaryRow = 4 + this._daysInMonth; + var summaryResultCell = this._sheet.getRange(summaryRow, 15).getA1Notation(); + var checkFormula = '='; + var checkLegendFormula = '='; + + checkFormula += summaryResultCell + '-' + this._sheet.getRange(summaryRow, 11).getA1Notation(); + checkLegendFormula += '' + summaryResultCell + '-' + this._sheet.getRange(2 + this._daysInMonth, 18).getA1Notation(); + + this._setupCheckArea(summaryRow, 19, checkFormula, checkLegendFormula); + }, + + /** + * Подгоняет размеры листа под необходимые, а так же применяет общие стили к ячейкам. + * + * @author Vyatcheslav Suharnikov + */ + _initialize: function () { + var sheet = this._sheet; + + // Сначала удалим все, кроме 1 ячейки (ее не можем удалить). + if (sheet.getMaxRows() > 1) { + sheet.deleteRows(1, sheet.getMaxRows() - 1); + } + + if (sheet.getMaxColumns() > 1) { + sheet.deleteColumns(1, sheet.getMaxColumns() - 1); + } + + // При создании новых ячеек, стиль будет копироваться из первой. + sheet.getRange(1, 1) + .setBackground('#efefef') + .setVerticalAlignment('middle'); + + // Добавим строк и столбцов столько, сколько нам нужно. + var rows = 3 // Заголовок. + + this._daysInMonth // Количество дней. + + 1 // Итого. + + 6; // Подвал. + + var cols = 16 // Основная таблица. + + 4; // Легенда. + + sheet.insertRowsBefore(1, rows - 1); + sheet.insertColumnsBefore(1, cols - 1); + }, + + /** + * Рисует заголовки основной таблицы со смещением (startColumn, startRow). + * + * @param {number} startRow Смещение по строкам. + * @param {number} startColumn Смещение по столбцам. + * + * @author Vyatcheslav Suharnikov + */ + _setupHeader: function (startRow, startColumn) { + var sheet = this._sheet; + + // Устанавливаем ширину колонок. + var columnsWidth = [ + 90, // Дата. + 105, // День недели. + + // Проект 1. + 250, // Задачи + 50, // Общее затраченное время. + + // Проект 2. + 250, // Задачи + 50, // Общее затраченное время. + + // Проект 3. + 250, // Задачи + 50, // Общее затраченное время. + + // Проект 4. + 250, // Задачи + 50, // Общее затраченное время. + + 50, // С. + 50, // По. + 50, // С. + 50, // По. + 50, // Часы. + 50, // D. + 50 // Суммарное время. + ]; + + for (var columnIndex = 0; columnIndex < columnsWidth.length; columnIndex++) { + sheet.setColumnWidth(startColumn + columnIndex, columnsWidth[columnIndex]); + } + + // Общие стили. + sheet.getRange('A1:P3').offset(startRow - 1, startColumn - 1) + .setFontWeight('bold') + .setHorizontalAlignment('center') + .setBorder(true, true, true, true, true, true); + + // Устанавливаем текст в заголовках. + sheet.getRange(startRow, startColumn, 1, 3).setValues([ + ['Дата', 'День', 'Отчет о рабочем времени'] + ]); + + sheet.getRange(startRow, startColumn + 2).setValue('Отчет о рабочем времени %username%'); + + sheet.getRange(startRow + 1, startColumn + 2).setValue('Задачи'); + sheet.getRange(startRow, startColumn + 10).setValue(this._reportName); + + sheet.getRange(startRow + 1, startColumn + 10, 1, 6).setValues([ + ['С', 'По', 'С', 'По', 'Часы', 'D'] + ]); + + // Разукрашиваем. + var fillRanges = { + 'A1:P3': '#969696', + 'C1:P1': '#99ccff', + 'C3:J3': '#ffcc99' + }; + + for (var range in fillRanges) { + sheet.getRange(range).offset(startRow - 1, startColumn - 1) + .setBackgroundColor(fillRanges[range]); + } + + // Устанавливаем необходимый размер шрифтов. + var fontSizeRanges = { + 'A1:P3': 12, + 'C1': 14 + }; + + for (var range in fontSizeRanges) { + sheet.getRange(range).offset(startRow - 1, startColumn - 1) + .setFontSize(fontSizeRanges[range]); + } + + // Сливаем ячейки. + var mergeRanges = [ + 'C1:J1', // Отчет о … + 'K1:P1', // Месяц и год. + 'A1:A3', // Дата. + 'B1:B3', // День. + 'C2:J2', // Задачи. + 'C3:D3', // Проект 1. + 'E3:F3', // Проект 2. + 'G3:H3', // Проект 3. + 'I3:J3', // Проект 4. + 'K2:K3', // С. + 'L2:L3', // По. + 'M2:M3', // С + 'N2:N3', // По. + 'O2:O3', // Часы. + 'P2:P3' // D. + ]; + + for (var i in mergeRanges) { + sheet.getRange(mergeRanges[i]).offset(startRow - 1, startColumn - 1) + .merge(); + } + }, + + /** + * Настройка строк для всех дней. + * + * @param {number} startRow Стартовая строка. + * @param {number} startColumn Стартовый столбец. + * @private + */ + _setupDayRows: function (startRow, startColumn) { + var sheet = this._sheet, + currDayDate = this._date; + + for (var i = 0; i < this._daysInMonth; i++) { + currDayDate.setDate(i + 1); + this._setupDayRow(startRow + i, startColumn, currDayDate); + } + + sheet.getRange(startRow, startColumn, this._daysInMonth, 16) + .setBorder(true, true, true, true, true, true); + }, + + /** + * Настройка строки для конкретного дня. + * + * @param {number} startRow Стартовая строка. + * @param {number} startColumn Стартовый столбец. + * @param {Date} dayDate Для какого дня настраиваем? + * @private + */ + _setupDayRow: function (startRow, startColumn, dayDate) { + var sheet = this._sheet, + backgroundColor = '#fff'; // Фон строки. + + if (reports.Utils.isHoliday(dayDate)) { + backgroundColor = '#ccc'; + } else if (reports.Utils.isPreHoliday(dayDate)) { + backgroundColor = '#efefef'; + } + + // Стили. + sheet.getRange(startRow, startColumn, 1, 16) + .setBackgroundColor(backgroundColor) + .setFontWeight('normal') + .setHorizontalAlignment('left'); + + // Расположение текста в "С, По, С, По, Часы, D". + sheet.getRange(startRow, startColumn + 10, 1, 6) + .setHorizontalAlignment('right'); + + // Текст, формулы и формат. + sheet.getRange(startRow, startColumn) + .setNumberFormat('dd.MM.YYYY') + .setValue(dayDate); + + // Наименовение дня недели. + sheet.getRange(startRow, startColumn + 1).setValue(reports.Utils.getDayName(dayDate)); + + // Проект 1-4 - затраченное время. + var cols = [3, 5, 7, 9]; + for (var i in cols) { + sheet.getRange(startRow, startColumn + cols[i]) + .setNumberFormat('0.00'); + } + + // С, По, С, По. + sheet.getRange(startRow, startColumn + 10, 1, 4) + .setNumberFormat('00:00'); + + // Часы. + var firstPartFormula = 'HOUR(R[0]C[-1] - R[0]C[-2])+MINUTE(R[0]C[-1] - R[0]C[-2])/60', + secondPartFormula = 'HOUR(R[0]C[-3] - R[0]C[-4])+MINUTE(R[0]C[-3] - R[0]C[-4])/60'; + sheet.getRange(startRow, startColumn + 14) + .setNumberFormat('0.00') + .setFormulaR1C1('=' + firstPartFormula + ' + ' + secondPartFormula); + + var expectedWorkTime = reports.Utils._getExpectedWorkHours(dayDate); + + // D. + sheet.getRange(startRow, startColumn + 15) + .setNumberFormat('0.00') + .setFormulaR1C1('=R[0]C[-1] - ' + expectedWorkTime); + }, + + /** + * Строка "Итого". + * + * @param {number} startRow Стартовая строка. + * @param {number} startColumn Стартовый столбец. + * @private + */ + _setupSummaryRow: function (startRow, startColumn) { + var sheet = this._sheet; + + // Стиль для ячеек по умолчанию. + sheet.getRange(startRow, startColumn, 1, 16) + .setFontWeight('bold') + .setBackgroundColor('#969696') + .setBorder(true, true, true, true, true, true); + + // Первые два столбца объединяем. + sheet.getRange(startRow, startColumn, 1, 2) + .merge(); + + // Суммарное затраченное время по каждому проекту. + var columns = [3, 5, 7, 9]; // Индексы столбцов с ячейкой затраченного времени по отдельному проекту. + for (var i in columns) { + // Считаем, что summary идет сразу после данных по дням. + sheet.getRange(startRow, startColumn + columns[i]) + .setBackgroundColor('#ffcc99') + .setNumberFormat('0.00') + .setFormulaR1C1('=SUM(R[-' + (this._daysInMonth + 1) + ']C[0]:R[-1]C[0])'); + } + + // Суммарное затраченное время по всем проектам. + sheet.getRange(startRow, startColumn + 10) + .setBackgroundColor('#33cccc') + .setNumberFormat('0.00') + .setHorizontalAlignment('center') + .setFormulaR1C1('=SUM(R[0]C[-10]:R[0]C[-1])'); + + // Текст "Итого". + sheet.getRange(startRow, startColumn + 11, 1, 3) + .merge() + .setFontColor('#fff') + .setHorizontalAlignment('right') + .setValue('Итого:'); + + // Суммарное затраченное время. + sheet.getRange(startRow, startColumn + 14) + .setBackgroundColor('#33cccc') + .setHorizontalAlignment('right') + .setFormulaR1C1('=SUM(R[-' + (this._daysInMonth + 1) + ']C[0]:R[-1]C[0])'); + + // Суммарная дельта. Сколько часов мы еще не отработали в этом месяце? + var deltaCell = sheet.getRange(startRow, startColumn + 15) + .setBackgroundColor('#33cccc') + .setHorizontalAlignment('right') + .setFormulaR1C1('=SUM(R[-' + (this._daysInMonth + 1) + ']C[0]:R[-1]C[0])'); + + // Количество часов на этот месяц. + sheet.getRange(startRow, startColumn + 16) + .setBackgroundColor('#ccffcc') + .setHorizontalAlignment('center') + .setValue(-deltaCell.getValues()[0][0]); // Скопируем из дельты, только знак изменим на +. + }, + + /** + * Настройка подвала. + * + * @param {number} startRow Стартовая строка. + * @param {number} startColumn Стартовый столбец. + * @private + */ + _setupFooter: function (startRow, startColumn) { + var sheet = this._sheet, + helpText1 = + 'Данные в колонках Проект 1, Проект 2 …\n' + + 'заполняются в часах\n' + + 'например, "3 часа 15 минут" заполняется как "3,25"\n' + + 'при необходимости используются формулы "=3+15/60"', + helpText2 = + 'Данные в прочих задачах\n' + + 'заполняются текстом\n' + + 'с указанием номеров задач', + helpText3 = + 'Данные в колонках "С", "По"\n' + + 'заполняются в часах и минутах\n' + + 'например, "половина второго"\n' + + 'заполняется как "13:30"'; + + // Вспомонательный текст 1. + sheet.getRange(startRow + 1, startColumn, 4, 3) + .merge() + .setBackgroundColor('#fff') + .setHorizontalAlignment('left') + .setFontSize(10) + .setValue(helpText1); + + // Вспомонательный текст 2. + sheet.getRange(startRow + 1, startColumn + 8, 4, 2) + .merge() + .setBackgroundColor('#fff') + .setHorizontalAlignment('left') + .setFontSize(10) + .setValue(helpText2); + + // Вспомонательный текст 3. + sheet.getRange(startRow + 1, startColumn + 10, 4, 6) + .merge() + .setBackgroundColor('#fff') + .setHorizontalAlignment('left') + .setFontSize(10) + .setValue(helpText3); + }, + + /** + * Легенда - список задач, над которыми сотрудник работал в течении месяца. + * + * @param {number} startRow Стартовая строка. + * @param {number} startColumn Стартовый столбец. + * @private + */ + _setupLegend: function (startRow, startColumn) { + // Установим ширину столбцов. + var sheet = this._sheet, + columnsWidth = [ // Ширина 1, 2 и третьей колонок. + 50, + 80, + 315 + ], + text = 'В легенду вписываются все задачи,\n' + + 'которые в отчете фигурируют только по ID', + rowsInLegend = this._daysInMonth; + + for (var columnIndex = 0; columnIndex < columnsWidth.length; columnIndex++) { + sheet.setColumnWidth(startColumn + columnIndex, columnsWidth[columnIndex]); + } + + // Общие стили для строк в легенде. + sheet.getRange(startRow + 1, startColumn, rowsInLegend + 1, 3) + .setBackgroundColor('#fff'); + + // Заголовок. + sheet.getRange(startRow, startColumn + 1, 1, 2) + .merge() + .setFontWeight('bold') + .setBackgroundColor('#969696') + .setFontColor('#fff') + .setHorizontalAlignment('center') + .setValue('Легенда'); + + // Количество часов. + sheet.getRange(startRow + 1, startColumn, rowsInLegend, 1) + .setNumberFormat('0.00') + .setHorizontalAlignment('right') + .setBorder(true, true, true, true, true, true); + + // Проект/Задача. + sheet.getRange(startRow + 1, startColumn + 1, rowsInLegend, 1) + .setHorizontalAlignment('center') + .setFontWeight('bold') + .setBorder(true, true, true, false, true, true); + + // Описание задачи. + sheet.getRange(startRow + 1, startColumn + 2, rowsInLegend, 1) + .setHorizontalAlignment('left') + .setBorder(true, false, true, false, true, true); + + // Итого. + sheet.getRange(startRow + rowsInLegend + 1, startColumn) + .setFontWeight('bold') + .setBackgroundColor('#99ccff') + .setHorizontalAlignment('right') + .setFormulaR1C1('=SUM(R[-' + rowsInLegend + ']C[0]:R[-1]C[0])'); + + // Текст с описанием. + sheet.getRange(startRow + rowsInLegend + 1, startColumn + 1, 2, 2) + .merge() + .setBackgroundColor('#fff') + .setHorizontalAlignment('left') + .setFontSize(10) + .setValue(text); + }, + + /** + * Настройка области для проверки введеных значений. + * + * @param startRow + * @param startColumn + * @param checkRangeFormula + * @param checkLegendRangeFormula + * @private + */ + _setupCheckArea: function (startRow, startColumn, checkRangeFormula, checkLegendRangeFormula) { + var sheet = this._sheet, + text = 'Если в полях "Проверка" или "Проверка по легенде" указано не "0,00"\n' + + 'это означает, что не все рабочее время\n' + + 'расписано в проектах и прочих задачах (общей таблице)\n' + + 'или, соответственно, в легенде (таблице справа).'; + + // Общие стили. + sheet.getRange(startRow, startColumn, 2, 2) + .setBackgroundColor('#ff8080') + .setFontWeight('bold') + .setHorizontalAlignment('center'); + + // Заголовки. + sheet.getRange(startRow, startColumn, 1, 2) + .setValues([['Проверка', 'Проверка по легенде']]); + + // Проверка. + sheet.getRange(startRow + 1, startColumn) + .setFormula(checkRangeFormula); + + // Проверка по легенде. + sheet.getRange(startRow + 1, startColumn + 1) + .setFormula(checkLegendRangeFormula); + + // Описание. + sheet.getRange(startRow + 2, startColumn, 5, 2) + .merge() + .setBackgroundColor('#fff') + .setFontSize(10) + .setHorizontalAlignment('left') + .setValue(text); + } +}; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..d59dcbc --- /dev/null +++ b/src/utils.js @@ -0,0 +1,238 @@ +// Вспомогательные функции: праздники, написание дат на русском языке и т.д.. +// В пространстве имен reports.utils. +if (typeof reports === 'undefined') { + reports = {}; +} + +/** + * Вспомогательные утилиты. + * + * @constructor + */ +var Utils = function () { +}; + +/** + + Чтобы получить значения для очередного года: + + 1. Зайдите на страницу производственного календаря сайта superjob.ru (для других не получится), + например, http://www.superjob.ru/proizvodstvennyj_kalendar/ . + 2. Откройте консоль Google Chrome: cmd + shift + i + 3. Выполните указанный ниже код: + + var holidays = { holiday: [], pre: [] }; + $('.pk_container').each(function(i, monthElement) { + var $monthElement = $(monthElement); + + holidays.holiday[i + 1] = $.map($monthElement.find('.pk_holiday'), function (dayElement) { return +dayElement.textContent.toString(); }); + holidays.pre[i + 1] = $.map($monthElement.find('.pk_preholiday'), function (dayElement) { return +dayElement.textContent.toString(); }); + }); + JSON.stringify(holidays); + + 4. Скопировать получившийся результат без обрамляющих кавычек ({…}) + 5. Вставить в _holidays с соотв. годом, например: + + this._holidays = { 2079: {…} }; + + */ +Utils._holidays = { + 2014: { + "holiday": [null, [1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 18, 19, 25, 26], [1, 2, 8, 9, 15, 16, 22, 23], [1, 2, 8, 9, 10, 15, 16, 22, 23, 29, 30], [5, 6, 12, 13, 19, 20, 26, 27], [1, 2, 3, 4, 9, 10, 11, 17, 18, 24, 25, 31], [1, 7, 8, 12, 13, 14, 15, 21, 22, 28, 29], [5, 6, 12, 13, 19, 20, 26, 27], [2, 3, 9, 10, 16, 17, 23, 24, 30, 31], [6, 7, 13, 14, 20, 21, 27, 28], [4, 5, 11, 12, 18, 19, 25, 26], [1, 2, 3, 4, 8, 9, 15, 16, 22, 23, 29, 30], [6, 7, 13, 14, 20, 21, 27, 28]], + "pre": [null, [], [24], [7], [30], [8], [11], [], [], [], [], [], [31]] + }, + 2015: { + "holiday": [null, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 17, 18, 24, 25, 31], [1, 7, 8, 14, 15, 21, 22, 23, 28], [1, 7, 8, 9, 14, 15, 21, 22, 28, 29], [4, 5, 11, 12, 18, 19, 25, 26], [1, 2, 3, 4, 9, 10, 11, 16, 17, 23, 24, 30, 31], [6, 7, 12, 13, 14, 20, 21, 27, 28], [4, 5, 11, 12, 18, 19, 25, 26], [1, 2, 8, 9, 15, 16, 22, 23, 29, 30], [5, 6, 12, 13, 19, 20, 26, 27], [3, 4, 10, 11, 17, 18, 24, 25, 31], [1, 4, 7, 8, 14, 15, 21, 22, 28, 29], [5, 6, 12, 13, 19, 20, 26, 27]], + "pre": [null, [], [], [], [30], [8], [11], [], [], [], [], [3], [31]] + } +}; + +// Наименование дней на русском языке. +Utils._dayNames = [ + 'Воскресение', // "Я вижу слезы ватников" (С) Ванга. + 'Понедельник', + 'Вторник', + 'Среда', + 'Четверг', + 'Пятница', + 'Суббота' +]; + +Utils.monthNames = [ + 'Январь', + 'Февраль', + 'Март', + 'Апрель', + 'Май', + 'Июнь', + 'Июль', + 'Август', + 'Сентябрь', + 'Октябрь', + 'Ноябрь', + 'Декабрь' +]; + +Utils.prototype = { + /** + * Праздник? + * + * @param {Date} date + * @returns {boolean} + */ + isHoliday: function (date) { + var year = date.getFullYear(), + month = date.getMonth() + 1, + day = date.getDate(), + dayOfWeek = date.getDay(), + holidays = this._getHolidaysInfoForYear(year).holiday; + + return dayOfWeek === 0 // Воскресение. + || dayOfWeek === 6 // Суббота. + || holidays[month] && holidays[month].indexOf(day) >= 0; // Или есть в списке выходных. + }, + + /** + * Предпраздничный день? + * + * @param {Date} date + * @returns {boolean} + */ + isPreHoliday: function (date) { + var year = date.getFullYear(), + month = date.getMonth() + 1, + day = date.getDate(), + pre = this._getHolidaysInfoForYear(year).pre; + + return pre[month] && pre.indexOf(day) >= 0; + }, + + /** + * Возвращает наименование дня на русском языке. + * В Google App Scripts на данный момент (02.12.2014) не реализовано, даже со сменой локали! + * + * @param {Date} date + * @returns {string} + */ + getDayName: function (date) { + return Utils._dayNames[date.getDay()]; + }, + + /** + * Возвращает первый день текущего месяца. + * При форматировании возможны приколы с часовыми поясами, гугл переодически лихорадит. + * + * @returns {Date} + */ + getCurrentMonthFirstDayDate: function () { + var result = new Date(); + result.setDate(1); + result.setHours(15); // Возможно придется сменить. + result.setMinutes(59); + result.setSeconds(59); + + return result; + }, + + /** + * Возвращает первый день для следующего месяца. + * Возможны приколы с часовыми поясами, гугл переодически лихорадит. + * + * @returns {Date} + */ + getNextMonthFirstDayDate: function () { + var currDate = new Date(); + + // Добавим 15:59:59, иначе форматирование будет тупить из-за приколов с часовыми поясами. + return new Date(currDate.getFullYear(), currDate.getMonth() + 1, 1, 15, 59, 59); + }, + + /** + * Количество дней в месяце. + * + * @param {Date} date В каком таком месяце? + * @returns {number} + */ + getDaysInMonth: function (date) { + var lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); + return lastDayOfMonth.getDate(); + }, + + /** + * Форматирует дату в виде "Декабрь 2015". + * + * Факин гугл не умеет месяца по-русски (02.12.2014). Не веришь (а мб сделали)? Попробуй: + * SpreadsheetApp.getActiveSpreadsheet().setSpreadsheetLocale('RU_ru'); + * return Utilities.formatDate(date, 'GMT', 'MMMM Y'); + * + * @param {Date} date + * @returns {string} + */ + formatDateMMMMY: function (date) { + return Utils.monthNames[date.getMonth()] + ' ' + date.getYear(); + }, + + /** + * Проверяет и в случае необходимости перемещает вкладку (по имени) на указанную позицию. + * + * @param {string} sheetName Наименование вкладки. + * @param {number} position Позиция, которую необходимо установить для вкладки. >= 1. + */ + ensureSheetNameIndex: function (sheetName, position) { + var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName); + + if (sheet) { + this.ensureSheetIndex(sheet, position); + } + }, + + /** + * Проверяет и в случае необходимости перемещает вкладку на указанную позицию. + * + * @param {Sheet} sheet Вкладка. + * @param {number} position Позиция, которую необходимо установить для вкладки. >= 1. + */ + ensureSheetIndex: function (sheet, position) { + var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); + + spreadsheet.setActiveSheet(sheet); + spreadsheet.moveActiveSheet(position); + }, + + /** + * Информация по праздникам за указанный год. + * + * @param {number} year + * @returns {*} Объект с двумя свойствами: holiday (список праздничных дней) и pre (список предпраздничных дней). + * @private + */ + _getHolidaysInfoForYear: function (year) { + if (!Utils._holidays[year]) { + throw new Error('Данные по праздникам за ' + year + ' не введены, обратитесь к документации в reports.Utils.'); + } + + return Utils._holidays[year]; + }, + + /** + * Возвращает ожидаемое количество часов работы в указанный день. + * + * @param {Date} date + * @return {number} + * + * @private + */ + _getExpectedWorkHours: function (date) { + var result = 8; // Значение по умолчанию. + + if (this.isHoliday(date)) { + result = 0; // В выходные не работаем. + } else if (this.isPreHoliday(date)) { + result -= 1; // В предпраздничные дни рабочий день сокращается на 1. + } + + return result; + } +}; + +reports.Utils = new Utils(); \ No newline at end of file diff --git a/tools/tasks/gs-export.js b/tools/tasks/gs-export.js new file mode 100644 index 0000000..5853ad3 --- /dev/null +++ b/tools/tasks/gs-export.js @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014 Vyatcheslav Suharnikov, contributors + * Licensed under the MIT license. + */ + +'use strict'; + +// DOESN'T WORK, BECAUSE OF GOOGLE! + +var path = require('path'); + +module.exports = function (grunt) { + /** + * Paths should be relative to the Gruntfile.js. + * + * @options options {string} srcDir A path to the directory, files imported in. + * @options options {string} exportDir A path to the file, exported from Google Drive. + * + * @see https://developers.google.com/apps-script/import-export + */ + grunt.registerTask('gs-export', 'Export a google script project', function () { + var options = this.options(); + + if (!options.srcDir) { + return grunt.log.error('Please specify the "srcDir" in options.'); + } + + var metaFilePath = path.join(options.srcDir, '.meta.json'); + if (!grunt.file.exists(metaFilePath)) { + return grunt.log.error('The meta file doesn\'n exist.'); + } + + var srcFiles = grunt.file.expand(path.join(options.srcDir, '*.js')); + if (srcFiles.length == 0) { + return grunt.log.error('There are no files to export.'); + } + + var meta = grunt.file.readJSON(metaFilePath), + result = {files: []}; + + // Collect files content. + var allFilesHaveMeta = srcFiles.every(function (filePath) { + var fileMeta = meta[filePath]; + if (!fileMeta) { + grunt.log.error('The file "' + filePath + '" hasn\'t meta information.'); + return false; + } + + fileMeta.source = grunt.file.read(filePath, {encoding: 'utf8'}); + result.files.push(fileMeta); + return true; + }); + + if (!allFilesHaveMeta) { + return grunt.log.error('There are files without meta. Exporting is failed.'); + } + + var exportedFilePath = path.join(options.exportDir, getExportFileName(options.filePrefix)); + grunt.file.write( + exportedFilePath, + JSON.stringify(result, null, '\t'), + {encoding: 'utf8'} + ); + + grunt.log.ok('The result is written to the "' + exportedFilePath + '" file.'); + }); + + function getExportFileName(prefix) { + return prefix + '_' + new Date().toJSON().replace(/[\:\.]/g, '') + '.json'; + } +}; diff --git a/tools/tasks/gs-import.js b/tools/tasks/gs-import.js new file mode 100644 index 0000000..60e6002 --- /dev/null +++ b/tools/tasks/gs-import.js @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2014 Vyatcheslav Suharnikov, contributors + * Licensed under the MIT license. + */ + +'use strict'; + +var path = require('path'); + +module.exports = function (grunt) { + + /** + * Paths should be relative to the Gruntfile.js. + * + * @options options {string} destDir A path to the directory, files imported in. + * @options options {string} importFilePath A path to the file, exported from Google Drive. + * + * @see https://developers.google.com/apps-script/import-export + */ + grunt.registerTask('gs-import', 'Import a google script project', function () { + var options = this.options(); + + if (!options.destDir) { + return grunt.log.error('Please specify the "destDir" in options.'); + } + + if (!options.importFilePath) { + return grunt.log.error('Please specify the "importFilePath" in options.'); + } + + var archive = grunt.file.readJSON(options.importFilePath); + if (!archive.files) { + return grunt.log.error('An invalid archive: the "files" element is expected.'); + } + + var meta = {}; + + // Import files to the destDir. + archive.files.forEach(function (fileEntry) { + var filePath = path.join(options.destDir, fileEntry.name + '.js'), + wasWritten = grunt.file.write(filePath, fileEntry.source, {encoding: 'utf8'}); + + meta[filePath] = { + id: fileEntry.id, + name: fileEntry.name, + type: fileEntry.type + }; + + if (wasWritten) { + grunt.log.ok('The file "' + filePath + '" was successfully imported.'); + } else { + grunt.log.error('Can\'t import the file: "' + filePath + '".'); + } + }); + + // Write a meta information. + var metaFilePath = path.join(options.destDir, '.meta.json'); + if (grunt.file.write(metaFilePath, JSON.stringify(meta, null, '\t'), {encoding: 'utf8'})) { + grunt.log.ok('The meta-file was written.'); + } else { + grunt.log.error('The meta-file wasn\'t written.'); + } + }); + +};