🎉

Redmineのview customizeを触った話

2024/10/01に公開

はじめに

私は、大学で機械工学を専攻しており、普段の生活でフロントエンドやバックエンドに触れる機会はほとんどありませんでした。プログラムに触れるといっても、CやC++が主であり、Web開発の経験はありませんでした。しかし、大学でのバイトを通じて、Redmineというプロジェクト管理ツールに出会い、フロントエンドやバックエンドの基礎を学ぶことができました。本記事では、その経験の中で特に学びが多かったRedmineのView Customizeプラグインを使ったカレンダーのCSV出力機能の実装について紹介します。

Redmineのview customizeプラグイン

Redmineは、プロジェクト管理ツールとして広く利用されています。私の大学でも、バイトの管理システムとしてRedmineが導入されていました。その中でもView Customizeプラグインは、Redmineの表示画面をJavaScriptやCSSを用いてカスタマイズすることができるツールです。これにより、RedmineのUIを自分好みに変更したり、特定の要素を操作することが可能になります。

RedmineのカレンダーのCSV出力

バイトのシフトはRedmine上のカレンダーにチケットを利用してシフトとすることでシフトカレンダーとして利用しています。
ここで職員さんからシフトカレンダーに登録された出勤状況から、各スタッフの月別・勤務者別の「勤務日数」「勤務時間」「時間外勤務時間」をCSVで算出し、集計する機能が要求として与えられました。
この要求に対して自分含む複数人のSSバイトにより以下の実装を行いました。

実装

概要

JavaScriptを使用して,Redmine上のカレンダーにてチケットを利用して作成されたシフトカレンダーからデータを収集・集計し、それをボタンを押すことによりCSV形式に変換してダウンロードするためのスクリプトを作成しました。

ライブラリの読み込み

head 要素を取得し、新しい <script> タグを作成して head に追加します。
文字化けやボタンがうまく表示されないことを防ぐためにencoding-japaneseライブラリを読み込み、末尾にてheadに追加します。

var head = document.getElementsByTagName('head')[0]
var script = document.createElement('script')
script.setAttribute("src", "https://unpkg.com/encoding-japanese@2.1.0/encoding.min.js")
script.setAttribute("type", "text/javascript")

head.appendChild(script)

ライブラリ読み取り後の処理

ライブラリが読み込まれた後に行う処理を定義し,ライブラリが完全に読み込まれる前に処理が始まるのを防ぎます。

script.addEventListener('load', function() {

CSVダウンロード機能の定義

downloadAsCsv() 関数は全体として、シフト情報の取得・勤務時間と超過勤務時間の計算・CSVデータの生成・CSVファイルのダウンロードを行っています。
ここでyearとmonthは、ユーザーが選択した年と月の値を取得します。

function downloadAsCsv() {
    const year = document.getElementById("year").value;
    const month = document.getElementById("month").value.padStart(2, "0");

シフト情報の取得と格納

Shift クラスは、シフト情報を管理します。HTML要素から日付、スタッフ名、シフト番号を取得し、これをインスタンス化します。
shiftElems は、特定のクラス (td.even や td.even today) を持つ div.issue 要素を全て取得します。
shifts は、取得した要素を Shift クラスのインスタンスに変換した配列です。

class Shift {
    constructor(div) {
        const day = div.closest("td.even").querySelector("p.day-num").textContent;
        this.date = `${year}-${month}-${day}`;
        this.staffName = div.dataset.staff;
        this.shiftNum = div.dataset.shift;
    }
}

const shiftElems = document.querySelectorAll("td[class='even'] div.issue, td[class='even today'] div.issue");
const shifts = [...shiftElems].map(shiftElem => new Shift(shiftElem));

シフト情報の整理と計算

datemap は、スタッフ名、日付、シフト番号、曜日でシフト情報を整理するためのマップです。
各シフト情報を datemap に整理して格納します。下のifの処理は階層構造によりスタッフ名、日付,シフト番号によりシフトの行われた曜日を整理するためのもので,それぞれに対応するマップがない場合、マップを作成し、シフト番号に関しては対応するマップを作成するのではなく,対応する曜日を作成しています。

const datemap = new Map();

for (const shift of shifts) {
    const date = shift.date;
    const staffName = shift.staffName;
    const shiftNum = shift.shiftNum;

    if (!datemap.has(staffName)) {
        datemap.set(staffName, new Map());
    }

    if (!datemap.get(staffName).has(date)) {
        datemap.get(staffName).set(date, new Map());
    }

    if (!datemap.get(staffName).get(date).has(shiftNum)) {
        const dayOfWeek = getDayOfWeek(date);
        datemap.get(staffName).get(date).set(shiftNum, dayOfWeek);       
    }
}

勤務時間の計算

上の項でも使用されているgetDayOfWeek() は、日付文字列から曜日を取得する関数です。
totalMap は、各スタッフの勤務時間・超過勤務時間・勤務日数・超過勤務日数の集計結果を格納するマップです。

function getDayOfWeek(dateString) {
    const daysOfWeek = ['日', '月', '火', '水', '木', '金', '土'];
    const date = new Date(dateString);
    const dayOfWeekIndex = date.getDay();
    return daysOfWeek[dayOfWeekIndex];
}

const totalMap = new Map();

for (const [staff, staffMap] of datemap.entries()) {
    let WorkedMinutes = 0; 
    let overtime = 0; 
    let overtimeDay = 0; 
    let shiftNumbers = [];
    let totalWorkedDays = 0; 

    const shiftsPerDate = new Map();
    
    // シフトごとの勤務時間の計算
    // 中略...

    const totalWorkedHours = WorkedMinutes / 60;
    const totaloverHours = overtime / 60;

    totalMap.set(staff, { formattedTime, formattedoverTime, totalWorkedDays, overtimeDay });
}

CSVファイル作成とダウンロード

csvData は、CSVファイルに書き込むデータの配列です。最初にヘッダー行をおき、各スタッフのデータを書き込みます。
ExcelにてCSVを文字化けさせずに表示させるためにsjisData と ui8a でデータをShift_JIS形式に変換し、バイナリデータとして保存します。
blob(Binary Large Object)を作成し、これをダウンロードリンクとして a 要素に設定し、プログラムでクリックすることでファイルをダウンロードさせます。

const csvData = [];

csvData.push("名前,勤務日数,勤務時間数,超過勤務時間数,超過勤務日数");

for (const [staff, { formattedTime, formattedoverTime, totalWorkedDays, overtimeDay }] of totalMap.entries()) {
    csvData.push(`${staff},${totalWorkedDays},${formattedTime},${formattedoverTime},${overtimeDay}`);
}

const csvText = csvData.join("\n");
const sjisData = Encoding.convert(csvText, {to: 'SJIS', type: 'array'});
const ui8a = new Uint8Array(sjisData);
const blob = new Blob([ui8a], { type: "text/csv" });

const objUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objUrl;

const filename = `MS勤務時間表_${year}年_${month}月.csv`;

a.download = filename;
a.click();
URL.revokeObjectURL(objUrl);

CSVダウンロードボタンの作成

newElement で新しいダウンロードボタンを作成し、ボタンクリック時に downloadAsCsv() 関数が呼び出されるように設定します。
作成したボタンを .buttons クラスの要素に追加します。

var newElement = document.createElement("a")
newElement.setAttribute("class", "icon icon-download")
newElement.setAttribute("href", "javascript:void(0)")
newElement.textContent = "CSVダウンロード"
newElement.addEventListener("click", downloadAsCsv)
document.querySelector(".buttons").appendChild(newElement)

実際の動作

上の手順を行うスクリプトをview customizeに登録し,ボタンを押すことによりシフトカレンダーに登録された出勤状況から、各スタッフの月別・勤務者別の「勤務日数」「勤務時間」「時間外勤務時間」をCSVで算出し、集計することができます

view customizeの登録画面

スクリプト適用前

スクリプト適用後

CSVファイルが出力される

スクリプト全体

function load() {
    "use strict";
    var head = document.getElementsByTagName('head')[0]
    var script = document.createElement('script')
    script.setAttribute("src", "https://unpkg.com/encoding-japanese@2.1.0/encoding.min.js")
    script.setAttribute("type", "text/javascript")
    
    script.addEventListener('load', function() {
        function downloadAsCsv() {
            const year = document.getElementById("year").value;
            const month = document.getElementById("month").value.padStart(2, "0");

            class Shift {
                constructor(div) {
                    const day = div.closest("td").querySelector("p.day-num").textContent;
                    this.date = ${year}-${month}-${day};
                    this.staffName = div.dataset.staff;
                    this.shiftNum = div.dataset.shift;
                }
            }

            const shiftElems = document.querySelectorAll("td.even:not(.closingday) div.issue");
            const shifts = [...shiftElems].map(shiftElem => new Shift(shiftElem));

            const datemap = new Map();

            for (const shift of shifts) {
                const date = shift.date;
                const staffName = shift.staffName;
                const shiftNum = shift.shiftNum;

                if (!datemap.has(staffName)) {
                    datemap.set(staffName, new Map());
                }

                if (!datemap.get(staffName).has(date)) {
                    datemap.get(staffName).set(date, new Map());
                }

                if (!datemap.get(staffName).get(date).has(shiftNum)) {
                    // 日付から曜日を取得
                    const dayOfWeek = getDayOfWeek(date);
                    datemap.get(staffName).get(date).set(shiftNum, dayOfWeek);       
                }

            }

            function getDayOfWeek(dateString) {
                const daysOfWeek = ['日', '月', '火', '水', '木', '金', '土'];
                const date = new Date(dateString);
                const dayOfWeekIndex = date.getDay();
                return daysOfWeek[dayOfWeekIndex];
            }

            const totalMap = new Map();

            for (const [staff, staffMap] of datemap.entries()) {
                let WorkedMinutes = 0; //勤務時間数
                let overtime = 0; //超過勤務時間数
                let overtimeDay = 0; //超過勤務日数
                let shiftNumbers = [];
                let totalWorkedDays = 0; // 勤務日数を初期化

                const shiftsPerDate = new Map();
            
                for (const [date, dayOfWeekMap] of staffMap.entries()) {
                    for (const [shiftNumber, day] of dayOfWeekMap.entries()) {
                        let additionalMinutes = 240; // デフォルトは4時間
                
                        if (day === '土') {  //土曜日の2枠は5時間
                            if (parseInt(shiftNumber, 10) === 2) {
                                additionalMinutes = 300;
                            }
                        } else if (day === '日') {  //日曜日の2枠は3時間
                            if (parseInt(shiftNumber, 10) === 2) {
                                additionalMinutes = 180;
                            }
                        }
                        WorkedMinutes += additionalMinutes;
                
                        //同日複数枠勤務
                        if (shiftsPerDate.has(date) && !shiftsPerDate.get(date).includes(shiftNumber)) {
                            // シフトナンバーを足し合わせて表示 どれかを判定している
                            const previousShiftNumber = shiftsPerDate.get(date)[0];
                            const sumShiftNumbers = parseInt(previousShiftNumber, 10) + parseInt(shiftNumber, 10);
                            console.log(Date: ${date}, Sum ShiftNumbers: ${sumShiftNumbers}, Staff: ${staff}, Day: ${day});
                            shiftNumbers.push(sumShiftNumbers);
                    
                            if (day === '土') {
                                // 土曜日はovertimeを4h足して、WorkedMinutesを4h+1h引く
                                console.log(Day: ${day});
                                overtime += 240;
                                overtimeDay++;
                                WorkedMinutes -= 300;
                            } else if (day === '日') {
                                // 日曜日は休憩時間分WorkedMinutes45減らす
                                console.log(Day: ${day});
                                WorkedMinutes -= 45;
                            } else {
                                const flag = parseInt(previousShiftNumber, 10) + parseInt(shiftNumber, 10);
                                            if (flag === 3) {
                                                // 平日の1,2の重複 休憩時間分の45分を減らす
                                                console.log(Day3: ${day});
                                                console.log( ShiftNumbers: ${previousShiftNumber}, ${shiftNumber});
                                                WorkedMinutes -= 45;
                                    
                                            } else if (flag === 4) {
                                                // 平日の1,3の重複 overtimeを4h足して、WorkedMinutesを4h引く
                                                console.log(Day4: ${day});
                                                console.log( ShiftNumbers: ${previousShiftNumber}, ${shiftNumber});
                                                overtime += 240;
                                                overtimeDay++;
                                                WorkedMinutes -= 240;
                                            } else if (flag === 5) {
                                                // 平日の2,3の重複
                                                console.log(Day5: ${day});
                                                console.log( ShiftNumbers: ${previousShiftNumber}, ${shiftNumber});
                                                WorkedMinutes -= 45;
                                            }
                            }//これで土日平日のすべての条件を満たす
                        } else {
                            if (!shiftsPerDate.has(date)) {
                                shiftsPerDate.set(date, []);
                            }
                                shiftsPerDate.get(date).push(shiftNumber);
                                shiftNumbers.push(parseInt(shiftNumber, 10));
                                totalWorkedDays++; // 勤務日数をインクリメント
                        }
                    }
                }

                        //勤務時間 00:00表示に変換
                            const totalWorkedHours = WorkedMinutes / 60;
                            const hours = Math.floor(totalWorkedHours);
                            const minutes = Math.round((totalWorkedHours - hours) * 60);
                            const formattedHours = hours.toString().padStart(2, '0');
                            const formattedMinutes = minutes.toString().padStart(2, '0');
                            const formattedTime = ${formattedHours}:${formattedMinutes};
                        //超過時間 00:00表示に変換
                            const totaloverHours = overtime / 60;
                            const overhours = Math.floor(totaloverHours);
                            const overminutes = Math.round((totaloverHours - overhours) * 60);
                            const formattedoverHours = overhours.toString().padStart(2, '0');
                            const formattedoverMinutes = overminutes.toString().padStart(2, '0');
                            const formattedoverTime = ${formattedoverHours}:${formattedoverMinutes};

                            // Total Mapにデータを追加または更新
                            totalMap.set(staff, { formattedTime, formattedoverTime, totalWorkedDays, overtimeDay });
            }

            const csvData = [];
        
            // Add header to CSV
            csvData.push("名前,勤務日数,勤務時間数,超過勤務時間数,超過勤務日数");
        
            // Add data to CSV
            for (const [staff, { formattedTime, formattedoverTime, totalWorkedDays, overtimeDay }] of totalMap.entries()) {
                csvData.push(${staff},${totalWorkedDays},${formattedTime},${formattedoverTime},${overtimeDay});
            }
            //csvの中身完成
        
            // SJISにエンコード excelで文字化けしない
            const csvText = csvData.join("\n");
            const sjisData = Encoding.convert(csvText, {to: 'SJIS', type: 'array'});
            const ui8a = new Uint8Array(sjisData);
            const blob = new Blob([ui8a], { type: "text/csv" });
        
            // Create Blob and download link//リンク作成のち関連づける
            // const blob = new Blob([csvText], { type: "text/csv" });
            const objUrl = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = objUrl;
        
            const filename = MS勤務時間表_${year}年_${month}.csv;
        
            a.download = filename;
            a.click();
            URL.revokeObjectURL(objUrl);
        }

        //ダウンロードボタンを追加
        var newElement = document.createElement("a")
        newElement.setAttribute("class", "icon icon-download")
        newElement.setAttribute("href", "javascript:void(0)")
        newElement.textContent = "CSVダウンロード"
        newElement.addEventListener("click", downloadAsCsv)
        document.querySelector(".buttons").appendChild(newElement)
        
    })

    head.appendChild(script)

}


load();

おわりに

これまでの経験を通じて,フロントエンドとバックエンドの両方に触れることで,全体像を理解することができました.大学での機械工学の学びとは異なる分野ではありますが,これらの経験により,自分の専攻分野のハードウェアとソフトウェアが密接に連携する分野(例えば機械を制御するためのOS等)を理解しやすくなり,情報セキュリティについての知識を開発・研究する上で常に頭で意識できるのではないかと考えています.

TryAngle@大阪公立大学

Discussion