勤怠管理アプリをカスタマイズしてみよう

著者名:武井 琢治

目次

caution
警告

Moment.js はメンテナンスモードになり、 日付処理できる代替ライブラリへの移行 (External link) が推奨されています。
代替ライブラリのひとつ Luxon (External link) については、 Luxon を使って kintone の日付や日時フィールドのフォーマットをカスタマイズする があります。

はじめに

こんにちは。kintone をより良くする武井です。
皆さん、kintone で勤怠管理をしていませんか?kintone の活用方法としては定番ですよね!
今回はそんな勤怠管理アプリで使えそうなカスタマイズを紹介したいと思います。

完成形イメージ

今回のカスタマイズでできること

レコード追加画面で、起算日から 1 ヵ月間の日付と始業/終業時刻を自動的にデフォルトセットできます。
カスタマイズで以下の入力内容をチェックします。

  • 勤務時間が 8 時間未満になっていないか。
  • 算定対象外の勤怠日付が含まれていないか。
  • すべての営業日が含まれているか。
  • 休日が含まれていないか。

アプリ準備

勤怠管理アプリ

以下のフィールドをもつ勤怠管理アプリを作成します。

フィールドコード フィールドタイプ 備考
総勤務時間 計算 計算式:SUM(勤務時間)
表示形式:時間
例:26時間3分
有休日数 数値 初期値:0
Table テーブル 以下の項目をテーブル化する
日付 日付
開始 時刻
終了 時刻
休憩 数値
勤務時間 計算 計算式:終了 - 開始 - 休憩 * 60
表示形式:時間
例:26時間3分
有休 チェックボックス 選択項目は 有休 のみ

また、「アプリの設定 > JavaScript / CSS でカスタマイズ」に以下の JavaScript/CSS ファイルを設定します。

PC 用の JavaScript ファイル
PC 用の CSS ファイル

休日マスターアプリ

以下のフィールドをもつ休日マスターアプリを作成します。

フィールドコード フィールドタイプ 備考
日付 日付
  • 必須項目
  • 値の重複禁止

作成後、土日祝や創業記念日などの休日レコードを作成します。
この際、Excel 等でデータを作っておき、CSV で一括インポートすると便利です。
CSV インポートについては ファイルからレコードのデータをアプリに読み込む (External link) を参照ください。

サンプルコード

以下が kintai.js のサンプルコードです。
サンプルコードはエディタにコピーして、ファイル名を kintai.js、文字コードを UTF-8 で保存します。
ファイル名は任意ですが、ファイルの拡張子は js にしてください。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
/*
 * 勤怠管理アプリのカスタマイズサンプルプログラム
 * Copyright (c) 2017 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

jQuery.noConflict();
(function($) {
  'use strict';

  const holidayMST = 278; // 休日マスタのアプリID
  const kisanbi = 11; // 起算日
  const start = '09:00'; // デフォルト始業時間
  const end = '18:00'; // デフォルト終業時間

  // 全レコード取得関数
  function fetchRecords(appId, query, opt_offset, opt_limit, opt_records) {
    const offset = opt_offset || 0;
    const limit = opt_limit || 500;
    let allRecords = opt_records || [];
    const params = {
      app: appId,
      query: query + ' limit ' + limit + ' offset ' + offset
    };
    return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', params).then((resp) => {
      allRecords = allRecords.concat(resp.records);
      if (resp.records.length === limit) {
        return fetchRecords(appId, query, offset + limit, limit, allRecords);
      }
      return allRecords;
    });
  }

  // 休日取得関数
  function fetchHoliday() {
    const today = parseInt(moment().format('D'), 10);
    let max, min;

    if (today < kisanbi) {
      min = moment().add(-1, 'months').format('YYYY-MM-' + kisanbi);
      max = moment().format('YYYY-MM-' + (kisanbi - 1));
    } else if (kisanbi === 1) {
      min = moment().format('YYYY-MM-0' + kisanbi);
      max = moment().endOf('month').format('YYYY-MM-DD');
    } else if (kisanbi < 10) {
      min = moment().format('YYYY-MM-0' + kisanbi);
      max = moment().add(1, 'months').format('YYYY-MM-0' + (kisanbi - 1));
    } else {
      min = moment().format('YYYY-MM-' + kisanbi);
      max = moment().add(1, 'months').format('YYYY-MM-' + (kisanbi - 1));
    }
    return fetchRecords(holidayMST, '日付 >= "' + min + '" and 日付 <= "' + max + '" order by 日付 asc').then((holidayRecs) => {
      const result = {
        maxDate: max,
        minDate: min,
        holidays: holidayRecs
      };
      return result;
    });
  }

  kintone.events.on(['app.record.create.show', 'app.record.edit.show'], (event) => {
    const record = event.record;

    $(kintone.app.record.getHeaderMenuSpaceElement())
      .append('<button id="btn" class="gaia-ui-actionmenu-save">入力内容チェック</button');

    // 入力内容チェック処理
    $('#btn').on('click', () => {
      fetchHoliday().then((resp2) => {
        // 営業日を格納
        const workDate = {};
        let tmpMinDate2 = resp2.minDate;
        const days2 = (moment(resp2.maxDate).diff(moment(tmpMinDate2), 'days') + 1);
        for (let n = 0; n < days2; n++) {
          let flg2 = true;
          for (let o = 0; o < resp2.holidays.length; o++) {
            if (tmpMinDate2 === resp2.holidays[o].日付.value) {
              flg2 = false;
              break;
            }
          }
          if (flg2) {
            workDate[tmpMinDate2] = tmpMinDate2;
          }
          tmpMinDate2 = moment(tmpMinDate2).add(1, 'days').format('YYYY-MM-DD');
        }

        const rec = kintone.app.record.get();
        const err = [];
        for (let l = 0; l < rec.record.Table.value.length; l++) {
          const workTime = rec.record.Table.value[l].value.勤務時間.value;
          const subTableDate = rec.record.Table.value[l].value.日付.value;
          // 算定期間外日付が入っていないか
          if ((subTableDate <= resp2.maxDate && subTableDate >= resp2.minDate) !== true) {
            err.push((l + 1) + '行目の日付は算定期間外です。');
            continue;
          }
          // 勤務時間は8時間以上か
          if (workTime >= '08:00' !== true) {
            err.push((l + 1) + '行目の勤務時間が8時間以上ではありません。');
          }
          // 休日が入っていないか
          for (let m = 0; m < resp2.holidays.length; m++) {
            const holidayDate = resp2.holidays[m].日付.value;
            if (subTableDate === holidayDate) {
              err.push((l + 1) + '行目の日付は休日です。');
              break;
            }
          }
          // 営業日がすべて入っているか
          if (workDate[subTableDate]) {
            delete workDate[subTableDate];
          }
        }
        $.map(workDate, (value) => {
          err.push(value + 'の勤怠記録がありません。');
        });

        if (err.length > 0) {
          swal('エラーがあります', err.join().replace(/,/g, 'n'), 'error');
        } else {
          swal('エラーはありません', '', 'success');
        }
      });
    });

    record.有休日数.disabled = true;
    new kintone.Promise((resolve, reject) => {
      if (event.type === 'app.record.create.show') {
        return fetchHoliday().then((resp) => {
          let tmpMinDate = resp.minDate;
          const days = (moment(resp.maxDate).diff(moment(tmpMinDate), 'days') + 1);
          for (let k = 0; k < days; k++) {
            let flg = false;
            for (let j = 0; j < resp.holidays.length; j++) {
              if (tmpMinDate === resp.holidays[j].日付.value) {
                flg = true;
                break;
              }
            }
            if (!flg) {
              const newRow = {
                value: {
                  日付: {
                    type: 'DATE',
                    value: tmpMinDate
                  },
                  開始: {
                    type: 'TIME',
                    value: start
                  },
                  終了: {
                    type: 'TIME',
                    value: end
                  },
                  休憩: {
                    type: 'NUMBER',
                    value: '60'
                  },
                  勤務時間: {
                    type: 'CALC',
                    value: ''
                  },
                  有休: {
                    type: 'CHECK_BOX',
                    value: []
                  }
                }
              };
              record.Table.value.push(newRow);
            }
            tmpMinDate = moment(tmpMinDate).add(1, 'days').format('YYYY-MM-DD');
          }
          record.Table.value.shift();
          resolve();
        });
      }
      resolve();
    }).then(() => {
      kintone.app.record.set(event);
      return event;
    });
  });

  const events = ['app.record.create.change.有休', 'app.record.edit.change.有休', 'app.record.create.change.Table', 'app.record.edit.change.Table'];
  kintone.events.on(events, (event) => {
    const record = event.record;
    let cnt = 0;
    for (let i = 0; i < record.Table.value.length; i++) {
      if (record.Table.value[i].value.有休.value.length === 1) {
        cnt++;
      }
    }
    record.有休日数.value = cnt;
    return event;
  });
})(jQuery);

プログラム解説

レコード一括取得をした時に 1 万件を超える可能性がある場合は、運用・適用中のプログラムのご確認と修正対応の検討をお願いします。
詳細は offset の制限値を考慮した kintone のレコード一括取得について を確認ください。
以下、スポット解説していきます。

13
14
15
16
const holidayMST = 278; // 休日マスターのアプリID
const kisanbi = 11; // 起算日
const start = '09:00'; // デフォルト始業時間
const end = '18:00'; // デフォルト終業時間

holidayMST 変数は上記で作成した「休日マスター」のアプリ ID を入力します。
kisanbi 変数は起算日としたい日付を入力します。たとえば、毎月 11 日から翌月 10 日までを算定日とする場合は「11」を入力します。
start 変数はデフォルトセットしたい始業時間を入力します。
end 変数はデフォルトセットしたい終業時間を入力します。

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 休日取得関数
function fetchHoliday() {
  const today = parseInt(moment().format('D'), 10);
  let max, min;

  if (today < kisanbi) {
    min = moment().add(-1, 'months').format('YYYY-MM-' + kisanbi);
    max = moment().format('YYYY-MM-' + (kisanbi - 1));
  } else if (kisanbi === 1) {
    min = moment().format('YYYY-MM-0' + kisanbi);
    max = moment().endOf('month').format('YYYY-MM-DD');
  } else if (kisanbi < 10) {
    min = moment().format('YYYY-MM-0' + kisanbi);
    max = moment().add(1, 'months').format('YYYY-MM-0' + (kisanbi - 1));
  } else {
    min = moment().format('YYYY-MM-' + kisanbi);
    max = moment().add(1, 'months').format('YYYY-MM-' + (kisanbi - 1));
  }
  return fetchRecords(holidayMST, '日付 >= "' + min + '" and 日付 <= "' + max + '" order by 日付 asc').then((holidayRecs) => {
    const result = {
      maxDate: max,
      minDate: min,
      holidays: holidayRecs
    };
    return result;
  });
}

該当する休日を「休日マスター」から取得する関数です。
たとえば、本日が 2/17 で起算日を「11」とする場合、2/11~3/10 までに存在する休日を取得します。

取得したら連想配列に格納して返却しています。
この際、非同期関数に配慮し、kintone.Promise を利用しています。

67
68
$(kintone.app.record.getHeaderMenuSpaceElement())
  .append('<button id="btn" class="gaia-ui-actionmenu-save">入力内容チェック</button');

レコード追加画面や編集画面の上部に「入力内容チェック」のボタンを配置しています。

70
71
72
// 入力内容チェック処理
$('#btn').on('click', () => {
  fetchHoliday().then((resp2) => {

「入力内容チェック」ボタンが押下された時のイベントです。
非同期処理(休日取得)が完了後、次の処理へ移ります。
「resp2」に取得した各データが格納されています。

73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 営業日を格納
const workDate = {};
let tmpMinDate2 = resp2.minDate;
const days2 = (moment(resp2.maxDate).diff(moment(tmpMinDate2), 'days') + 1);
for (let n = 0; n < days2; n++) {
  let flg2 = true;
  for (let o = 0; o < resp2.holidays.length; o++) {
    if (tmpMinDate2 === resp2.holidays[o].日付.value) {
      flg2 = false;
      break;
    }
  }
  if (flg2) {
    workDate[tmpMinDate2] = tmpMinDate2;
  }
  tmpMinDate2 = moment(tmpMinDate2).add(1, 'days').format('YYYY-MM-DD');
}

算定月の営業日を配列に格納しています。
先ほど取得した休日以外の日になります。

 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
for (let l = 0; l < rec.record.Table.value.length; l++) {
  const workTime = rec.record.Table.value[l].value.勤務時間.value;
  const subTableDate = rec.record.Table.value[l].value.日付.value;
  // 算定期間外日付が入っていないか
  if ((subTableDate <= resp2.maxDate && subTableDate >= resp2.minDate) !== true) {
    err.push((l + 1) + '行目の日付は算定期間外です。');
    continue;
  }
  // 勤務時間は8時間以上か
  if (workTime >= '08:00' !== true) {
    err.push((l + 1) + '行目の勤務時間が8時間以上ではありません。');
  }
  // 休日が入っていないか
  for (let m = 0; m < resp2.holidays.length; m++) {
    const holidayDate = resp2.holidays[m].日付.value;
    if (subTableDate === holidayDate) {
      err.push((l + 1) + '行目の日付は休日です。');
      break;
    }
  }
  // 営業日がすべて入っているか
  if (workDate[subTableDate]) {
    delete workDate[subTableDate];
  }
}
$.map(workDate, (value) => {
  err.push(value + 'の勤怠記録がありません。');
});

入力内容チェックの本丸処理です。
テーブルに入力されている行数分だけ繰り返し処理しています。
各チェック後に、エラーがあれば err 変数にプッシュしています。

122
123
124
125
126
if (err.length > 0) {
  swal('エラーがあります', err.join().replace(/,/g, '\n'), 'error');
} else {
  swal('エラーはありません', '', 'success');
}

エラーがひとつでもある場合は err 配列の内容を表示し、エラーがひとつも存在しない場合はその旨を表示します。

130
record.有休日数.disabled = true;

有休日数のフィールドを入力不可にしています。
有休日数は JavaScript 処理でチェック数を計算します。

131
132
133
134
new kintone.Promise((resolve, reject) => {
  if (event.type === 'app.record.create.show') {
    return fetchHoliday().then((resp) => {
      // ~後略~

レコード追加画面で算定月の行をデフォルトセットする処理です。
デフォルトで用意されている行は不要なので削除しています。

188
189
190
191
192
193
194
195
196
197
198
199
const events = ['app.record.create.change.有休', 'app.record.edit.change.有休', 'app.record.create.change.Table', 'app.record.edit.change.Table'];
kintone.events.on(events, (event) => {
  const record = event.record;
  let cnt = 0;
  for (let i = 0; i < record.Table.value.length; i++) {
    if (record.Table.value[i].value.有休.value.length === 1) {
      cnt++;
    }
  }
  record.有休日数.value = cnt;
  return event;
});

有休のチェックボックスを計算する処理です。
テーブル自体の change イベントも設定することで、行自体が削除されてもチェック数を変更できるようにしています。

機能拡張

さらに本プログラムを拡張することで、以下のようなことも可能になります。

おわりに

いかがでしたでしょうか。
標準機能で出来ないことはカスタマイズすると実現できます。
少しの手間でも、これからずっとその手間がなくなると思えば大きいですよね。
皆様のすばらしい kintone カスタマイズライフの一助となれたら幸いです。