SEじゃない人にウケが良かったRedmainのカスタマイズ


# はじめに

とても便利なプラグイン「ViewCustomizePlugin」を使ってのカスタマイズです。
あっちこっち参考にさせて貰ったので、自分も還元。

# 動作環境

- Windows Server 2019 Standard
- Bitnami Redmine Stack 4.1.1-4
- View Customize plugin 2.7.0

# テーブルの横幅変えられるのと、くっつくヘッダ

これはSEの人にも、頑張って作ってって言われた。
リサイズはラッパーかまして、resizableがそのまま使えた。
ヘッダ追従はヘッダだけコピーしたテーブル作って、コピーテーブルをスクロールイベで制御してます。
（チケット名と日付だけは、文字列折返ししたかったので、コード内で設定）

ヘッダの追従は色々試したり、調べた結果結構な力技になった印象…。
 ~~ガントチャートの方のヘッダ追従誰か作って…~~ 

![Videotogif (1) (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1210891/8e032e03-da21-ba61-e39d-65ac3eed95a7.gif)

## ソースコード

```javascript
/*
Path pattern: /issues$
Type: JavaScript
テーブルの列の横幅リサイズ可能と、ヘッダのスクロール追従(ラッパー追加とヘッダコピーの関係上分離できなかった…)
*/
$(window).on('load', function() {
  // th,tdのセレクタ名生成
  var $th_selecter = $('table.list th');
  var $td_selecter = $('table.list td');
  var $table_list = $('table.list');

  // フロートヘッダ用のスティッキーテーブル生成
  $table_list.before('<table class="list issues odd-even sort-by-id sort-desc sticky" style="table-layout: fixed; position:fixed; top: 0px; display: none; z-index:2; transition: .0s;"> </table>');
  // ヘッダをコピー
  $('table.list thead').clone().appendTo('table.sticky');
  // スティッキーテーブルのセレクタ名生成
  var $sticky_table = $('table.sticky');
  var $sticky_selecter = $('table.sticky th');
  // スティッキーテーブル用の各要素取得
  var headerHeight = $sticky_table.outerHeight();
  var startPos = 0;
  var tableTop = $table_list.offset().top;
  var tableLeft = $table_list.offset().left;
  // ブラウザ全体のスクロールイベント
  $(window).on('load scroll', function() {
    var scroll_top = $(this).scrollTop();
    // スクロール位置がテーブル位置の上まで来たら、フロートヘッダを表示
    if(scroll_top > tableTop) {
      $sticky_table.css('display', 'table');
    } else {
      $sticky_table.css('display', 'none');
    }
    startPos = scroll_top;
  });
  // テーブル内の横スクロールイベント
  $('div.autoscroll').on("scroll", function(){
    // フロートヘッダの位置を横スクロールに合わせて移動
    $sticky_table.css("left", -$(this).scrollLeft() + tableLeft);
  });

  // resizable設定用のラッパーを作成
  $th_selecter.wrapInner('<div class="resizable_wrap">'); 
  $td_selecter.wrapInner('<div class="resizable_wrap">'); 
  $sticky_selecter.wrapInner('<div class="resizable_wrap">'); 
  // フロートヘッダと通常ヘッダを同期させるために、列毎に取り出す
  $th_selecter.each(function(index, element) {
    // リサイズ設定。横のみ。リサイズを行う時にtablelayoutをfixedに変更(overflowを有効にするため。最初からfixedに設定するとレイアウトが崩れるのでリサイズ行う時のみ)。
    $(this).resizable({
      alsoResize: $sticky_selecter.eq(index),
      handles: 'e',
      start: function() { setTableFixed() }
    });
    $sticky_selecter.eq(index).resizable({
      alsoResize: $(this),
      handles: 'e',
      start: function() { setTableFixed() }
    });
  });
  
  $th_selecter.each(function(index, element) {
     // layout:fixedにすると横幅が崩れるので横幅を固定設定
     $(this).width( $(this).width() );
     $sticky_selecter.eq(index).width( $(this).width() );
  });
  // 元テーブルの横幅取得
  setTableWidth();
  
  // テーブルサイズの変更イベントあると、テーブルサイズが変わるため横幅再取得
  $('table.list').resize(function() {
    setTableWidth();
  });
  // Windowの変更イベントあると、テーブルサイズが変わるため横幅再取得
  $(window).resize(function() {
    setTableWidth();
  });
  
  function setTableWidth() {
    $sticky_table.css('width', $('table.list').not('.sticky').width());
  }
  
  function setTableFixed() {
    $table_list.css('table-layout','fixed');
    // 最初から自動改行にすると横幅が取れないので、リサイズ後にチケットの題名を自動改行するように変更
    $('.subject .resizable_wrap').css('white-space','normal');
    $('.parent-subject .resizable_wrap').css('white-space','normal');
    $('.due_date .resizable_wrap').css('white-space','normal');
  }
});
```

## CSS

作業時間とレポートはリサイズだけ抜き出して適応してます。

```css
/*
Path pattern: /issues$|/time_entries$|/time_entries/report$
Type: JavaScript
横幅のリサイズ時に文字幅以下になったら文字を隠す
*/
.resizable_wrap{
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
```

# くっついてくる横スクロールバー

「一番下まで行かないと横スクロールできないから常に表示できない？」って言われて、「ホイールクリックでなんとかして」って答えたけどヘッダと同じ要領で出来そうだから作った。

チケット一覧のスクロールバーと横幅が同じ、スクロールバーだけの要素追加して、追加したスクロールバーとチケット一覧のスクロールバーの動き同期してる。

![Videotogif (1) (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1210891/8d5d4371-9a6c-aea8-dd2a-15cafc5d7914.gif)


## ソースコード

div.autoscrollを\#gantt_areaに変えればガントチャートでも使えます。

```javascript
/*
Path pattern: /issues$|/time_entries$
Type: JavaScript
チケット一覧に横スクロールバーの表示
*/
$(window).on('load', function() {
  $autoscroll = $('div.autoscroll');
  
  // スクロールバー用の要素挿入
  $autoscroll.append ('<div class="issue_hor_scroll" style="display: block;height: 18px;width: 0px;position: fixed;bottom: 0px;overflow-x: scroll;overflow-y: hidden;"> <div class="max_hor_scroll" style="width: 0px;height: 1px;"></div> </div>');
  var $scroll_bar = $('div.issue_hor_scroll');
  
  // スクロールバーの表示設定
  function showScrollBar() {
    var autoscroll_bottom = $autoscroll.offset().top + $autoscroll.height();
    var scroll_top = $(this).scrollTop() + $(this).height();
    // スクロールバーが最大値以下かつ、スクロール位置がテーブル上まで来たら、スクロールバーを表示
    if($autoscroll.get(0).scrollWidth > $autoscroll.width() && scroll_top < autoscroll_bottom) {
      $scroll_bar.css('display', 'block');
    } else {
      $scroll_bar.css('display', 'none');
    }
    // スクロール位置同期
    $scroll_bar.scrollLeft($autoscroll.scrollLeft());
  }
  // スクロールバーの幅設定
  function setScrollBar() {
    $('div.max_hor_scroll').css('width', $autoscroll.get(0).scrollWidth);
    $scroll_bar.css('width', $autoscroll.width());
  }
  setScrollBar();
  showScrollBar();
  
  // ブラウザ全体のスクロールイベント
  $(window).on('load scroll', function() {
    showScrollBar();
  });
  
  // スクロールバーのスクロールイベント
  $scroll_bar.on('load scroll', function() {
    // スクロール位置同期
    $autoscroll.scrollLeft($(this).scrollLeft());
  });
  // スクロールバーのスクロールイベント
  $autoscroll.on('load scroll', function() {
    // スクロール位置同期
    $scroll_bar.scrollLeft($(this).scrollLeft());
  });
  
  // テーブルサイズの変更イベント
  $('table.list').resize(function() {
    setScrollBar();
    showScrollBar();
  });
  // テーブルサイズの変更イベント
  $(window).resize(function() {
    setScrollBar();
    showScrollBar();
  });
});
```


# ガントチャートの遅れ進捗率を赤字に

進捗遅れのチケットの指定要素探すのが、地味に大変だった…。

赤いバーの表示（or期限切れ）要素からチケットID取得して、同じチケットIDのラベルがあったら赤文字に設定。

![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1210891/5ca0047d-e712-357b-bb30-f238a3192312.png)


## ソースコード


```javascript
/*
Path pattern: /issues/gantt$
Type: JavaScript
ガントチャートの進捗遅れチケットのラベルを赤字に設定
*/
$(window).on('load', function() {
  // 遅れバー(赤)が表示されてる要素 issue-behind-schedule icon icon-issue
  $('span.issue-behind-schedule').each(function() {
    // 遅れチケットの番号取得
    var late_issue = $(this).not('.issue-closed').parent().attr('id');
    $('div.task.label').each(function(i) {
      var issue_no = $(this).attr('data-collapse-expand');
      // 遅れチケットと同じだったら赤字設定
      if(issue_no == late_issue) {
        $(this).css('color','red');
      }
    });
  });
});
```

# Redmine Work Time pluginのステータスと進捗率の連動

自動入力みたいな、操作が減る改造はウケが良かった。

普通のステータスと進捗率の連動のコードは他であると思うので、こっちのせます。
valueにMがくっつくので、ちょっと面倒くさい。
（特定操作でMの数が合わなくなって破綻した気がする……リロードすると直るから放置）

※valueの値がチケットのステータスによって違うので注意

## ソースコード

```javascript
/*
Path pattern: /work_time
Type: JavaScript
work_timeのステータスと進捗率の連動
*/
$(function() {
  function status_new(_this) {
    // ステータスのid生成
    var statud_id=_this.attr('id').replace(/done_ratio/g, 'status_id');
    $('#'+statud_id).children('[value=1]').remove();
    $('#'+statud_id).children('[value=M1]').remove();
    $('#'+statud_id).append($('<option value="M1">新規</option>'));
    $('#'+statud_id).val('M1');
  }
  function status_proseccing(_this) {
    // ステータスのid生成
    var statud_id=_this.attr('id').replace(/done_ratio/g, 'status_id');
    $('#'+statud_id).children('[value=2]').remove();
    $('#'+statud_id).children('[value=M2]').remove();
    $('#'+statud_id).append($('<option value="M2">進行中</option>'));
    $('#'+statud_id).val('M2');
  }
  function status_done(_this) {
    // ステータスのid生成
    var statud_id=_this.attr('id').replace(/done_ratio/g, 'status_id');
    $('#'+statud_id).children('[value=5]').remove();
    $('#'+statud_id).children('[value=M5]').remove();
    $('#'+statud_id).append($('<option value="M5">完了</option>'));
    $('#'+statud_id).val('M5');
  }
  
  // ステータスが変わった場合
  $('select[id*="status_id"]').change(function(e) {
    // 進捗率のid生成
    var done_ratio_id=$(this).attr('id').replace(/status_id/g, 'done_ratio');
    // 選択してるvalue取得(何故か選択する度Mが付くので数字のみに)
    var select_val=$(this).val().replace(/[^0-9]/g, '');

    // ステータスに応じて進捗率を設定
    // 例: 終了→進捗率100％を設定
    switch(select_val) {
      case '1':
        $('#'+done_ratio_id).val('0');
        break;
      case '5':
        $('#'+done_ratio_id).val('100');
        break;
      case '7':
        $('#'+done_ratio_id).val('100');
        break;
    }
  });
  // 進捗率が変わった場合
  $('select[id*="done_ratio"]').change(function(e) {
    // 選択してるvalue取得(何故か選択する度Mが付くので数字のみに)
    var select_val=$(this).val();
    // 進捗率に応じてステータスを設定
    // 例: 進捗率100％→終了を設定
    switch(select_val) {
      case '0':
        status_new($(this));
      break;
      case '10':
        status_proseccing($(this))
      break;
      case '20':
        status_proseccing($(this))
      break;
      case '30':
        status_proseccing($(this))
      break;
      case '40':
        status_proseccing($(this))
      break;
      case '50':
        status_proseccing($(this))
      break;
      case '60':
        status_proseccing($(this))
      break;
      case '70':
        status_proseccing($(this))
      break;
      case '80':
        status_proseccing($(this))
      break;
      case '90':
        status_proseccing($(this))
      break;
      case '100':
        status_done($(this))
      break;
    }
  });
});
```