デモで学ぶ業務アプリのUI開発(3) – WBS(プロジェクト工数管理表)

メシウスのWebサイトではJavaScriptライブラリを活用した業務アプリのUIの実装サンプルを、ダウンロード可能なソースコード付きで多数公開しています。

本連載ではWebサイトで公開しているサンプルアプリケーションを例に、さまざまな業務アプリケーションのUIの実装のポイントや、コントロールの選定ポイントを解説していきます。第3回となる本記事ではJavaScript UIライブラリ「Wijmo(ウィジモ)」の各種コントロールを使用した「WBS(プロジェクト工数管理表)」デモの解説です。

本デモはデータグリッドコントロールの「FlexGrid」と「MultiRow」をベースにしたものと、スプレッドシートコントロールの「FlexSheet」をベースにしたものの2種存在します。
※ FlexGrid/MultiRow版はPureJS、FlexSheet版はAngularとReactのサンプルとして提供しています。

それぞれ同様の機能を実現していますが、使用するコントロールにより実装の方法や手間が異なっているので、その点も詳しく解説していきます。

WBS(プロジェクト工数管理表)

利用しているコントロール

WBSデモではWijmoの以下のコントロールを使用しています。

コントロール名利用目的対象デモその他
InputNumber1日の規定労働時間を入力するFlexGrid/MultiRow
image.png
InputDateプロジェクトの開発期間を入力する共通image.png
FlexGrid、MultiRowプロジェクトのWBSを表示するFlexGrid/MultiRow
image.png

image.png
FlexSheetFlexSheetimage.png
Popup1日の労働時間の不正入力時のアラート表示FlexGrid/MultiRow
image.png
FlexGridFilterWBSをフィルタするFlexGrid/MultiRow
(FlexGrid使用の画面のみ)

image.png
UndoStackUndo/Redoを機能を追加FlexGrid/MultiRow

各機能の解説

ここからは各コントロールのソースコード部分を抜粋し、ポイントとなる部分を解説します。

セル結合

FlexGrid(在庫管理システム開発タブ)、MultiRow(勤怠管理システム開発タブ)、FlexSheetそれぞれでセル結合して1レコード複数行を実現しています。MultiRowはlayoutDefinitionを使用してレコード内のセルを縦横いずれの方向にも結合することが可能です。一方、FlexGridとFlexSheetの場合、wijmo.grid.MergeManagerクラスを拡張して、そのオブジェクトを定義してグリッドのmergeManagerプロパティに割り当てることで実現しました。以下のFlexGridのカスタムセル結合のデモもご参考ください

FlexGridの例

export class RestrictedMergeManager extends MergeManager {
    getMergedRange(p, r, c, clip = true) {
        if (grayTextCellNames.includes(p.columns[c].binding)) {
            // create basic cell range
            var rng = null;
            // start with single cell
            rng = new CellRange(r, c);
            // expand up
            while (rng.row > 0 && rng.row % 2 !== 0) {
                rng.row--;
            }
            // expand down
            while (rng.row2 < p.rows.length - 1 && (rng.row2 + 1) % 2 !== 0) {
                rng.row2++;
            }
            // don't bother with single-cell ranges
            if (rng.isSingleCell) {
                rng = null;
            }
            // done
            return rng;
        }
    }
}

工程、タスク、担当者の列を2行単位でセル結合をしています。

FlexGridでセル結合

FlexSheetの例

// ベースヘッダーの行のセル結合
export const baseHeaderMerge = (
  headerStartRow: number,
  headerStartCol: number,
  flexSheet: FlexSheet,
) => {
  // カスタムMergeManagerを設定
  flexSheet.mergeManager = new MergeManager();
    flexSheet.mergeManager.getMergedRange = function(p, r, c, clip) {
      if (flexSheet.sheets.selectedIndex !== 0) {
        // 先頭シート以外は結合表示しない
        return null;
      }

      let rng = null;
      if (Object.values(BASE_HEADERS).length + headerStartCol > c
        && headerStartRow  < r) {
        // start with single cell
        rng = new CellRange(r, c);
        // expand up
        while (rng.row > 0 && rng.row % 2 === 0) {
            rng.row--;
        }
        // expand down
        while (rng.row2 < p.rows.length - 1 && (rng.row2 + 1) % 2 === 0) {
            rng.row2++;
        }
        // don't bother with single-cell ranges
        if (rng.isSingleCell) {
            rng = null;
        }
      // done
    }
    return rng;
  }
}

FlexSheetでは工程から工数消化率の列までを2行単位でセル結合をしています。

FlexSheetでセル結合

自動計算処理

それぞれのデモにおいて、入力されたデータに応じて実績や出来高、工数消化などが自動的に計算されます。

FlexGrid(MultiRow)の場合

FlexGrid(MultiRow)は計算に使用するセルの値が変更する度に、再計算してセルの値を更新する処理を実装する必要があります。例えば、セルに値が張り付けられた際や、セルの入力が完了した場合、行や列が追加・削除された場合などです。

export function getUpdatedView(s, e) {
    // FlexGridが描画された後に実行する
    recalculateActualTime(s); // 実績(d)の計算
    // 全体実績の計算
    calcAllCell(s.columns.getColumn(ActualTime).index, s.columns.getColumn(ActualTime).index, s);
    // 全体予定の計算
    calcAllCell(s.columns.getColumn(ScheduledTime).index, s.columns.getColumn(ScheduledTime).index, s);
    // 各々のその他の進捗率を計算
    PersonnelList.forEach((personnel) => {
        calcCommonProgressRate(0, s.columns.getColumn(Personnel).index, personnel, s);
    });
    calcProgressRate(0, s.columns.getColumn(Personnel).index, s); // 全体進捗率を計算
    // イベントハンドラーを解除
    s.updatedView.removeHandler(getUpdatedView);
}
export function getCalculatedFields() {
    return {
        yield: ($) => {
            if ($.isCopy)
                return "";
            else if ($.scheduledTime === undefined || $.progressRate === undefined)
                return 0;
            else
                return $.scheduledTime * $.progressRate;
        },
        manHourDigestionRate: ($) => {
            if ($.isCopy)
                return "";
            else if ($.scheduledTime === 0 ||
                $.actualTime === undefined ||
                $.scheduledTime === undefined ||
                $.actualTime === "" ||
                $.scheduledTime === "")
                return 0;
            else
                return $.actualTime / $.scheduledTime;
        },
    };
}

export function getPastedCell(s, e) {
    getCellEditEnded(s, e);
}

export function getCellEditEnded(s, e) {
    const col = s.columns[e.col];
    // コピーセルの工程・タスク担当者を一致させる処理
    // multrowの場合はスキップ
    if (isFlexGrid() && e.row % 2 === 0) {
        let grayTextIndex = grayTextCellNames.indexOf(col.binding);
        if (grayTextIndex !== -1) {
            s.setCellData(e.row + 1, s.columns.findIndex((c) => c.binding === grayTextCellNames[grayTextIndex]), s.getCellData(e.row, col.index, false));
        }
    }
    // 実績(d)の計算
    if (e.row % 2 !== 0 && col.binding.startsWith(dateCellBinding)) {
        calcActualTime(s, e.row);
        calcAllCell(s.columns.getColumn(ActualTime).index, s.columns.getColumn(ActualTime).index, s); // 全体実績の計算
    }
    // 全体予定の計算
    if (e.row % 2 === 0 && col.binding === ScheduledTime) {
        calcAllCell(e.col, e.col, s);
    }
    // 編集されたセルの担当を取得
    const editedPersonnel = s.getCellData(e.row, s.columns.getColumn(Personnel).index, true);
    setTimeout(() => {
        // その他行の進捗率の計算(出来高/予定時間) 出来高=予定時間x進捗率, 予定時間
        // 編集後の担当者の進捗率を更新
        calcCommonProgressRate(e.row, e.col, editedPersonnel, s);
        // 編集前の担当者の進捗率を更新(削除された担当者は除く)
        if (e.col === s.columns.getColumn(Personnel).index &&
            editedPersonnel !== e.data &&
            PersonnelList.includes(e.data) &&
            !(e.data === undefined || e.data === null || e.data === "")) {
            calcCommonProgressRate(e.row, e.col, e.data, s);
        }
        calcProgressRate(e.row, e.col, s); // 全体の進捗率を計算
    }, 0);
    if (e.col === s.columns.getColumn(Personnel).index) {
        // 新しい担当者がリストに存在しない場合、「その他」セルの行を追加
        // 既存の担当者リストを取得
        if (!PersonnelList.includes(editedPersonnel) && !isEmpty(editedPersonnel)) {
            let newRow = {
                process: CommonText,
                personnel: editedPersonnel,
                scheduledTime: 3,
                // progressRate: 0,
            };
            // 追加するIndexを取得
            let insertIndex = s.collectionView.sourceCollection.findIndex((item) => ![CommonText, AllText].includes(item.process) &&
                (item.isSample === undefined || !item.isSample));
            s.collectionView.sourceCollection.splice(insertIndex, 0, newRow);
            if (isFlexGrid()) {
                // コピー行
                let newCopyRow = {
                    process: CommonText,
                    personnel: editedPersonnel,
                    isCopy: true,
                    scheduledTime: "",
                };
                s.collectionView.sourceCollection.splice(insertIndex + 1, 0, newCopyRow);
            }
            PersonnelList.push(editedPersonnel);
        }
        // 担当者が消えた場合
        if (e.col === s.columns.getColumn(Personnel).index &&
            editedPersonnel !== e.data) {
            let personnelList = [
                ...new Set(s.collectionView.sourceCollection
                    .filter((item) => item.process !== CommonText)
                    .map((item) => item.personnel)),
            ];
            if (!personnelList.includes(e.data)) {
                let removeIndex = s.collectionView.sourceCollection.findIndex((item) => item.process === CommonText && item.personnel === e.data);
                // FlexGridの場合はコピー行が格納されているが、MultRowにはコピー行がない点を考慮して要素を削除
                let removeCount = isFlexGrid() ? 2 : 1;
                s.collectionView.sourceCollection.splice(removeIndex, removeCount);
                PersonnelList = PersonnelList.filter((item) => item !== e.data);
            }
        }
        s.collectionView.refresh();
    }
    // Number型のセルでないときに数値のフォーマットを適用させる
    let cellData = s.getCellData(e.row, col.index, false);
    if (!(col.dataType === DataType.Number) &&
        !isNaN(cellData) &&
        cellData !== "") {
        s.setCellData(e.row, e.col, Globalize.formatNumber(Number(cellData), "n2"));
    }
    // 新規行を追加
    if (e.row >= s.rows.length - 2) {
        addRow(s);
    }
}

FlexSheetの場合

FlexSheetで計算処理を実装する場合、セルの値に他セルを参照するような数式を設定できます。また数式を使用すれば、参照先のセルの値が(ユーザの入力やUndo/Redoなどの操作で)変更されてセルの値が変わっても再計算する必要はありません。

export const setFormula = (
  headerStartRow: number,
  headerStartCol: number,
  flexSheet: FlexSheet,
) => {
  const actualTimeIdx =
    Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ActualTime) + headerStartCol
  const yieldIdx = Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.Yield) + headerStartCol
  const scheduledTimeIdx =
    Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ScheduledTime) + headerStartCol
  const progressRateTimeIdx =
    Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ProgressRate) + headerStartCol
  const manHourDigestionRateIdx =
    Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ManHourDigestionRate) + headerStartCol

  // 設定のシートをがあれば設定値を参照する式を使用するようにする
  const settingSheetIdx = flexSheet.sheets.findIndex((sheet) => sheet.name === SETIING_SHEET)
  if (settingSheetIdx === -1) {
    console.warn(`${SETIING_SHEET}シートが見つかりません`)
  }
  const workHours = settingSheetIdx !== -1 ? '設定!A2' : WORKING_HOURS

  for (let row = 1 + headerStartRow; row < flexSheet.rows.length; row++) {
    const formulaActualTime = `=ROUND(SUM(${getColumnLetter(
      Object.values(BASE_HEADERS).length + headerStartCol + 1,
    )}${row + 2}:XFD${row + 2})/${workHours}, ${2})` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
    const formulaYield = `=${getColumnLetter(scheduledTimeIdx + 1)}${
      row + 1
    }*${getColumnLetter(progressRateTimeIdx + 1)}${row + 1}` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
    const condition = `${getColumnLetter(actualTimeIdx + 1)}${
      row + 1
    }/${getColumnLetter(scheduledTimeIdx + 1)}${row + 1}`
    const formulaManHourDigestionRate = `=IF(ISERROR(${condition}),"",${condition})` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる

    flexSheet.setCellData(row, actualTimeIdx, formulaActualTime)
    flexSheet.setCellData(row, yieldIdx, formulaYield)
    flexSheet.setCellData(row, manHourDigestionRateIdx, formulaManHourDigestionRate)

    const readOnlyCols = [actualTimeIdx, yieldIdx, manHourDigestionRateIdx] // 計算式の列は編集不可セル
    readOnlyCols.forEach((colIndex) => {
      flexSheet.columns[colIndex].isReadOnly = true
    })
  }
}

Undo/Redo

FlexSheetは標準でUndo/Redo機能が搭載されていますが、FlexGrid(MultiRow)には搭載されていないので、UndoStackを使用して機能を追加します。

export function createUndoStack(parentClassName, view) {
    // parentClassNameと子要素の間にformElementを追加する
    const parentElement = document.querySelector(`.${parentClassName}`);
    // 新しい要素を作成
    const formElement = document.createElement("form");
    formElement.id = `${parentClassName}-undoable-form`;
    // parentElementの子要素をnewElementに移動
    while (parentElement.firstChild) {
        formElement.appendChild(parentElement.firstChild);
    }
    // 新しい要素を追加
    parentElement.appendChild(formElement);
    let undoStack = new UndoStack(`#${formElement.id}`, {
        undoneAction: (s, e) => {
            getCellEditEnded(view, e.action);
        },
        redoneAction: (s, e) => {
            getCellEditEnded(view, e.action);
        },
    });
}

以下のように各プロジェクトで呼び出します。

・・・(中略)・・・
    DataCommonService.addRow(grid);
    DataCommonService.createWorkingHoursInputNumber("pj1-theInputNoSrc", grid);
    DataCommonService.createUndoStack("project_flexgrid1", grid);
    DataCommonService.setFilter(grid);
    grid.autoSizeColumns();
    return grid;
}

Excelエクスポート

それぞれのデモにおいて、各コンポーネントのsaveAsyncメソッド(FlexGridFlexSheet)を使用してExcelエクスポート機能を実装しています。

FlexGrid(MultiRow)の場合

document.querySelector("#saveXlsx").addEventListener("click", () => {
    if (!showGrid)
        return;
    let projectName = tabPanel.selectedTab.header.textContent;
    FlexGridXlsxConverter.saveAsync(showGrid, {
        includeColumnHeaders: true,
        includeStyles: false,
        formatItem: null,
    }, `${projectName}_${dateFns.format(new Date(), "yyyy-MM-dd-HH-mm-ss")}.xlsx`);
});

FlexSheetの場合

  save() {
    const fileName = 'FlexSheet.xlsx';

    // 非同期でExcelファイルにエクスポート
    if (!this.flex) return;
    applyMergesToModel(this.flex)
    this.flex.saveAsync(fileName);
  }

その際、FlexSheetで作成したWBSにおいては、制限はあるものの、セルに設定した数式など、アプリ上で使用していた機能をそのまま維持してExcelエクスポートできます。

さいごに

今回はWijmoの各種コントロールを使用した「WBS(プロジェクト工数管理表)」デモの機能の実装のポイントを解説しました。

今回は同様の画面をデータグリッド系のコンポーネントと、スプレッドシート系のコンポーネントそれぞれで作成していますが、この例では各種の計算に数式や関数を使用できる点や、Excelとの互換性の点で、スプレッドシートコントロールのFlexSheetを使用する方が優位な部分が多かったです。
また、今回のサンプルではExcelのように別のシートを追加して、一日の稼働時間やプルダウンメニューに表示する項目などを定義していました。別途設定値を入力したり保持したりする仕組みを作らなくてよいので、この点もFlexSheetならではの強みといえます。

製品サイトでは今回ご紹介したデモアプリケーションをブラウザ上で手軽にお試し可能で、加えてソースコード付きでダウンロードも可能なので、是非こちらもチェックしてみてください。

そのほか、製品サイトでは、Wijmoのトライアル版も公開しておりますので、こちらもご確認ください。

また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。

\  この記事をシェアする  /