もうBacklogやRedmineは要らない？ Exmentでガントチャート付きタスク管理を実践する

どうも、業務改善が大好きな筆者です。

さて、[連日Exmentの記事を書いている](https://qiita.com/tags/exment)わけですが、そろそろタスク管理についても触れたいなと思っています。

タスク管理といえば、TrelloやAsana、BacklogやRedmineなど、SaaSのサービスが乱立してますよね。（Redmineはオンプレも可能ですが）

一方で、Excelやスプレッドシートに書き込んで、お手製のタスク管理をされている方もいらっしゃると思います。

## ガントチャート好きな人、多いですよね

筆者個人が、タスク管理でどうしても外せないのが**ガントチャート**です。ガントチャート機能付きのタスク管理サービスというと、どうしても選択肢がせばまってきます。

今は[Toggl Plan](https://toggl.com/plan/product)という名前になってTogglファミリーとなった、かつてのTeamweekが、個人的には一番好きでした。

Redmineにもガントチャートがありますが、デフォルトのあれはダメでしたね。何が良くて何がダメなのか。それは**マウスでガントチャートを伸ばしたり縮めたりできるかどうか**だと思ってます。

Teamweekはそれが出来たのですが、Redmineはタスクの日付を変えない限り伸び縮みしてくれませんでした…（デフォルトの話です）

## ガントチャート使えるサービス、有料SaaSが多い件

Backlogのガントチャートもマウスで伸び縮みできるそうなんですが、Backlogは一零細副業家（筆者は副業の管理にExmentを試験導入しています）にとっては手が出せない価格なんですよね。基本1人で使うし…　Asanaも有料プランですし…

Redmineはオンプレで使えますが、ガントチャート自体の昨日が今一つなので、試してすぐやめました…

## もしガントチャート使えるのなら、タスク管理もExmentでやってみたい…

というわけで、これまでタスク管理は色々なものを主に無料プランで使ってきましたが、どれも一長一短で馴染むものがありませんでした。ガントチャートでいえばTeamweek（現：Toggl Plan）でしたが、カンバンの良さでいえばTrelloですよね…

そこに来て、現在筆者は

- [営業管理（SFA/CRM）](https://qiita.com/kohki-shikata/items/73ec72c0171bef0be25f)
- [コスト管理（原価計算）](https://qiita.com/kohki-shikata/items/7af13b1ac8c5be876243)

をExmentに集約しているので、タスク管理もここで案件と紐づけつつやってみたいなあ、という思いに至りました。なので、今回はExmentでタスク管理しつつ、API経由でガントチャートビューも実装してみたいと思います。

## フロントにFrappe Ganttを採用

色々なJavaScriptのガントチャートライブラリを一通り調べてみましたが、シンプルさと機能の両立で言うと、[Frappe Gantt](https://frappe.io/gantt)が良いな、と思い今回採用してみました。

![2020-09-15_10h14_42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116223/6f3e511f-b354-72ee-9e31-92545e41f7aa.png)

もちろん**マウスで伸び縮み**出来ますし、バーを横に動かすこともできます。しかも進捗率もマウスで変更できます。

それでは、実装していきましょう。

## Exment側のテーブル設計

まずは、Exment側のカスタムテーブルを用意します。カスタムテーブル名は「タスク管理」としました。

テーブル設計は

| 列名         | 列のタイプ                            |
|--------------|---------------------------------------|
| タイトル       | 1行テキスト                              |
| 開始日 | 日付と時刻                            |
| 終了日 | 日付と時刻                            |
| 担当者     | ユーザー                                  |
| 案件名       | 選択肢 (他のテーブルの値一覧から選択) |
| 進捗率 | 整数（0~100） |
| ステータス | 選択肢（値・見出しを登録） |

ステータスの値と見出しは以下の通りです。

```text
1,未整理
2,未着手
3,進行中
4,チェック待ち
5,完了
6,対応しない
```

## フロントはExpress + pugでHTMLを出力

[コスト管理（原価計算）](https://qiita.com/kohki-shikata/items/7af13b1ac8c5be876243)の時のように、Express + pugで出力します。この時のapp.jsを使いまわそうと思います。なので、Expressやpugはインストール済みという前提にしたいと思います。



```app.js
app.get('/tasks', async (req, res) => {
  
  ;(async () => {
    const exmentToken = JSON.parse(await fs.readFileSync('./exment_tokens.txt')).access_token
    let tasksData = await axios.get('https://example.com/api/data/tasks/?orderby=start_at', {
      headers: {
        'Authorization': 'Bearer ' + exmentToken
      }
    })
    .then(res => { return res.data.data })
    .catch(err => { console.error(err) })
  
    let tasksArray = []
    tasksData.forEach(item => {
      const id = item.id
      const name = item.value.title
      const start = moment(item.value.start_at).format('YYYY-MM-DD')
      const end = moment(item.value.end_at).format('YYYY-MM-DD')
      const progress = item.value.progress
      const custom_class = 'uplift-task'
      tasksArray.push({
        id: String(id),
        name: name,
        start: start,
        end: end,
        progress: progress,
        custom_class: custom_class
      })
    })
    // console.log(tasksArray)
    res.render('tasks-gantt', { tasks: JSON.stringify(tasksArray) })
  })()
})
```
まずは、タスクデータの取得です。ExmentのAPIからGETで取得し、Frappe Ganttのtaskデータ形式に整形しています。

次に、viewです。

```tasks-gantt.pug
<!DOCTYPE html>
html(lang="en")
  head
    meta(charset="UTF-8")
    meta(name="viewport", content="width=device-width, initial-scale=1.0")
    title Document
    link(rel="stylesheet", href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css", integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z", crossorigin="anonymous")
    .
      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/frappe-gantt/0.5.0/frappe-gantt.min.css" integrity="sha512-j2JEichKxgq6udg7yAJpsSwGLrHtxeTinqv4kzc+SXCJEhYzDT/JclOOOU28pD1jk1IEdt2FCQ/CnqdOzt7e6Q==" crossorigin="anonymous" />
  body
    #gantt
    .btn-group(role='group', aria-label='Basic example')
      button.btn.btn-secondary(type='button' onClick="changeDay()") Day
      button.btn.btn-secondary(type='button' onClick="changeWeek()") Week
      button.btn.btn-secondary(type='button' onClick="changeMonth()") Month

    .
      <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.26.0/moment.min.js" integrity="sha512-QkuqGuFAgaPp3RTyTyJZnB1IuwbVAqpVGN58UJ93pwZel7NZ8wJOGmpO1zPxZGehX+0pc9/dpNG9QdL52aI4Cg==" crossorigin="anonymous"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/snap.svg/0.5.1/snap.svg-min.js" integrity="sha512-Gk+uNk8NWN235mIkS6B7/424TsDuPDaoAsUekJCKTWLKP6wlaPv+PBGfO7dbvZeibVPGW+mYidz0vL0XaWwz4w==" crossorigin="anonymous"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/frappe-gantt/0.5.0/frappe-gantt.min.js" integrity="sha512-5M8ejeX3DuiV4VGIFjHP5gpryPQb1dWYjzTUhBUKj81aT6ZZz6+wIG8k89nbjsiHFJHQbi/CByHQTe4mJOi3hw==" crossorigin="anonymous"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.20.0/axios.min.js" integrity="sha512-quHCp3WbBNkwLfYUMd+KwBAgpVukJu5MncuQaWXgCrfgcxCJAq/fo+oqrRKOj+UKEmyMCG3tb8RB63W+EmrOBg==" crossorigin="anonymous"></script>
    script.
      const tasks = !{tasks}
      const options = {
        header_height: 50,
        column_width: 30,
        step: 24,
        view_modes: ['Day', 'Week', 'Month'],
        bar_height: 50,
        bar_corner_radius: 3  ,
        arrow_curve: 5,
        padding: 18,
        view_mode: 'Day',   
        date_format: 'YYYY-MM-DD',
        custom_popup_html: null,
        on_date_change: function(task, start, end) {
          axios.put('/tasks/date',{
            id: task.id,
            start_at: moment(start).format('YYYY-MM-DD HH:mm:ss'),
            end_at: moment(end).format('YYYY-MM-DD HH:mm:ss')
          })
          .then(function(res) {
            console.log(res)
          })
          .catch(function(err) {
            console.log(err)
          })
        },
        on_progress_change: function(task, progress) {
          console.log(task, progress);
          axios.put('/tasks/progress',{
            id: task.id,
            progress: progress
          })
          .then(function(res) {
            console.log(res)
          })
          .catch(function(err) {
            console.log(err)
          })
        },
      }
      const gantt = new Gantt('#gantt', tasks, options)

      function changeDay() {
        gantt.change_view_mode('Day')
      }
      function changeWeek() {
        gantt.change_view_mode('Week')
      }
      function changeMonth() {
        gantt.change_view_mode('Month')
      }
```
とりあえず動く、までが目標なので、あんまりきれいにまとまっていません。

まず、Frappe GanttはCDNから読み込みました。Frappe Ganttのdependanciesである、momentとsnap-svgもCDNから。Frappe GanttのinitもHTMLにべた書きです。

on_date_changeと、on_progress_changeが、バーを伸ばしたり進捗率を変更したときのコールバックになります。ここで、CDNから読み込んだaxiosを呼び出して、Exment側へPUTしています。

ExmentのAPIをJSから直接叩いても良かったのですが、認証のこともありますし、一旦Express側にAPIを作って、そこからデータを送っています。フロントとバックエンドで2回axiosのお世話になるという、なんだかなー、ということになってますが…

下記が、app.jsのPUTの箇所です。

```app.js
app.put('/tasks/date', async (req, res) => {
  ;(async () => {
    const exmentToken = JSON.parse(await fs.readFileSync('./exment_tokens.txt')).access_token

    // console.log(req.body)
    await axios.put('https://example.com/api/data/tasks/' + req.body.id, {
      value: {
        start_at: req.body.start_at,
        end_at: req.body.end_at
      }
    }, {
      headers: {
        'Authorization': 'Bearer ' + exmentToken
      }
    })
    .then(res => { return res })
    .catch(err => { console.error(err) })
  })()
})

app.put('/tasks/progress', async (req, res) => {
  ;(async () => {
    const exmentToken = JSON.parse(await fs.readFileSync('./exment_tokens.txt')).access_token

    // console.log(req.body)
    await axios.put('https://example.com/api/data/tasks/' + req.body.id, {
      value: {
        progress: req.body.progress
      }
    }, {
      headers: {
        'Authorization': 'Bearer ' + exmentToken
      }
    })
    .then(res => { return res })
    .catch(err => { console.error(err) })
  })()
})
```

さて、うまく行けば下記のようになっているはずです。

![2020-09-15_10h37_40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116223/695527b9-d167-d860-8281-19c0385843c3.png)

いい感じですね。

では、ドラッグアンドドロップのテストをしたいと思います。

先にExmentを開いて、開始日終了日の日付を確認しておきましょう。

![2020-09-15_10h39_31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116223/32f2d58e-918e-a195-0c9a-a9d808f43131.png)

↑まず、日付はこうなっています。

次に、ガントチャート側でバーをドラッグして長さも変更してみましょう。

![2020-09-15_10h40_44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116223/639d038a-8084-1961-7e87-39954b35ef1c.png)

ついでに、進捗率も変えちゃいましょう。

![2020-09-15_10h42_30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116223/304ab2c2-19e3-7350-8572-f8624dc5fa15.png)

では、Exment側に戻り、同じタスクの詳細画面をリロードします。

![2020-09-15_10h43_44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116223/77abfba1-a5fd-c1ed-a5a5-5eac33023eeb.png)

しっかりと変更されてました！

## しかし、課題もあります

### タスクの依存関係が作れない

Frappe Gantt側ではその機能はあるのですが、Exmentでバックエンドを実装すると、同じカスタムテーブル内のレコード同士を紐づける設定が今のところないので、タスク同士の紐づけができません。

無理やり整数のカラムを作って、レコードIDを手入力することも考えられますが、それも何だかなあといった感じです。

### 日本語化されてない

月の表記が英語です。月だけは日本語化できそうなので、暇なときにプルリク作って送っておきますか。

### 土日祝日の色変更ができない

なんかIssueにはそれっぽいのが上がってるのですが、結局どうやって変更すればいいのか分かりませんでした。

## ただまあ、個人的にはこれらの課題は些末なことだったので…

一旦ガントチャートにできれば十分だったので、この結果は満足しています。

もし、高度なガントチャートが必要であれば、別のライブラリを使えばいいと思います。

## まとめ

1. ExmentのAPIを使って、タスクをガントチャート化できるよ！（BacklogとかRedmineいらないかも）
1. ガントチャートライブラリは色々あるけど、マウスでバーを伸び縮みできるのがいいなら、Frappe Ganttがおすすめ（課題はあるけど）
1. ガントチャートライブラリは、お好みのものを選べばいいと思う。もっと高機能なものもあるしね！

といったところでしょうか。

個人的には、MFクラウド請求書で受注した見積書とタスクが紐づくことによって、Togglの記録とも連動させて

- 営業判断
- 工数原価管理
- タスク管理

が一元化したのが気持ちいいなー、と思っています。BacklogやRedmineも良いですが、Exmentでタスク管理始めてみませんか？