プロジェクト

全般

プロフィール

バグ #149

未完了

【実装】Redmineカンバンビュー拡張機能(task2.call2arm.com)

Redmine Admin さんが5日前に追加. 5日前に更新.

ステータス:
解決
優先度:
通常
担当者:
-
開始日:
2025-06-02
期日:
進捗率:

0%

予定工数:

説明

概要

Redmineカンバンビュー拡張機能の実装を行う。実装環境は task2.call2arm.com。

実装項目

  1. 開発環境セットアップ

    • プラグイン開発環境構築
    • バージョン管理設定
    • デバッグ環境構築
  2. チケットパネル詳細ボタン実装

    • HTMLテンプレート修正
    • CSS実装
    • JavaScript実装
  3. 担当者別カンバンビュー実装

    • コントローラー実装
    • モデル実装
    • ビュー実装
    • ルーティング設定
  4. 日次/週次/月次フィルター実装

    • フィルターUI実装
    • 日付計算ロジック実装
    • フィルタリング機能実装
  5. テスト実装

    • 単体テスト
    • 統合テスト
    • E2Eテスト

成果物

  • ソースコード
  • コメント付きコード
  • テストコード
  • 開発ドキュメント

参考コード

# チケットパネルに詳細ボタンを追加する例
# app/views/kanban/_issue_card.html.erb に追加

<div class="card-actions">
  <%= link_to l(:button_details), issue_path(issue), 
      :class => 'button details-button', 
      :title => l(:label_view_issue_details),
      :target => '_blank' %>
</div>

<style>
  .card-actions {
    margin-top: 5px;
    text-align: right;
  }
  
  .details-button {
    padding: 2px 5px;
    background-color: #f0f0f0;
    border: 1px solid #ccc;
    border-radius: 3px;
    font-size: 0.8em;
  }
  
  .details-button:hover {
    background-color: #e0e0e0;
  }
</style>
# 担当者別カンバンビューのコントローラーメソッド例
# app/controllers/kanban_controller.rb に追加

def assignee_view
  @project = Project.find(params[:project_id]) if params[:project_id]
  
  # フィルタ条件を設定
  @query = IssueQuery.new(:name => "_")
  @query.project = @project
  
  # 日付範囲フィルタ
  if params[:date_period]
    case params[:date_period]
    when 'daily'
      @start_date = Date.today
      @end_date = Date.today
    when 'weekly'
      @start_date = Date.today.beginning_of_week
      @end_date = Date.today.end_of_week
    when 'monthly'
      @start_date = Date.today.beginning_of_month
      @end_date = Date.today.end_of_month
    else
      # カスタム日付範囲
      @start_date = params[:start_date].present? ? params[:start_date].to_date : Date.today
      @end_date = params[:end_date].present? ? params[:end_date].to_date : Date.today
    end
    
    @query.add_filter('start_date', '>=', [@start_date.to_s])
    @query.add_filter('due_date', '<=', [@end_date.to_s])
  end
  
  # 担当者を取得
  @assignees = @project ? @project.members.map(&:user).uniq : User.active
  
  # ステータス(列)を取得
  @statuses = IssueStatus.all.sorted
  
  # 担当者ごとのチケットを整理
  @assignee_issues = {}
  @assignees.each do |assignee|
    query = @query.dup
    query.add_filter('assigned_to_id', '=', [assignee.id.to_s])
    @assignee_issues[assignee.id] = query.issues
  end
  
  render 'assignee_view'
end

現状

実装前の初期段階。設計の完了を待っている状態。

Redmine Admin さんが5日前に更新

親チケット #146 の子チケットとして設定します。

Redmine Admin さんが5日前に更新

実装コード続き

2.2 AssigneeKanbanBoard CSS (続き)

  padding: 0.75rem;
  border: 1px solid #ddd;
  vertical-align: top;
  background-color: #fff;
}

.assignee-info {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.assignee-avatar {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  object-fit: cover;
}

.assignee-name {
  font-weight: 500;
}

.kanban-card {
  background-color: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  margin-bottom: 0.75rem;
  padding: 0.75rem;
  position: relative;
}

.kanban-card-wrapper.is-dragging {
  opacity: 0.8;
}

.kanban-card-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 0.5rem;
}

.ticket-id {
  color: #666;
  font-size: 0.875rem;
}

.ticket-priority {
  font-size: 0.75rem;
  padding: 0.125rem 0.375rem;
  border-radius: 3px;
  color: #fff;
}

.kanban-card-body {
  margin-bottom: 1rem;
}

.ticket-subject {
  font-size: 0.9375rem;
  margin: 0 0 0.5rem;
  word-break: break-word;
}

.ticket-assignee,
.ticket-due-date {
  font-size: 0.8125rem;
  margin-bottom: 0.375rem;
}

.label {
  color: #666;
  margin-right: 0.25rem;
}

.ticket-progress {
  height: 6px;
  background-color: #eee;
  border-radius: 3px;
  margin-top: 0.5rem;
  position: relative;
}

.progress-bar {
  height: 100%;
  background-color: #4caf50;
  border-radius: 3px;
}

.progress-text {
  position: absolute;
  right: 0;
  top: -16px;
  font-size: 0.75rem;
  color: #666;
}

.kanban-card-footer {
  display: flex;
  justify-content: flex-end;
}

.detail-button {
  font-size: 0.75rem;
  padding: 0.25rem 0.5rem;
  background-color: #f0f0f0;
  border: 1px solid #ddd;
  border-radius: 3px;
  text-decoration: none;
  color: #333;
  transition: background-color 0.2s;
}

.detail-button:hover {
  background-color: #e0e0e0;
}

.loading {
  padding: 2rem;
  text-align: center;
  color: #666;
}

.error {
  padding: 1rem;
  color: #d32f2f;
  background-color: #ffebee;
  border-radius: 4px;
  margin-bottom: 1rem;
}

3. メインページコンポーネント

3.1 TicketsPage コンポーネント

// src/pages/TicketsPage.jsx
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-query';
import TicketsList from '../components/tickets/TicketsList';
import KanbanBoard from '../components/tickets/KanbanBoard';
import AssigneeKanbanBoard from '../components/tickets/AssigneeKanbanBoard';
import DateRangeFilter from '../components/filters/DateRangeFilter';
import { getMonthly } from '../utils/dateUtils';
import { fetchTickets, fetchStatuses, fetchUsers } from '../api/redmineApi';
import './TicketsPage.css';

const VIEW_TYPES = {
  LIST: 'list',
  KANBAN: 'kanban',
  ASSIGNEE_KANBAN: 'assigneeKanban'
};

const TicketsPage = () => {
  // ビュータイプの状態
  const [viewType, setViewType] = useState(
    localStorage.getItem('ticketsViewType') || VIEW_TYPES.LIST
  );
  
  // 日付範囲の状態
  const [dateRange, setDateRange] = useState(
    JSON.parse(localStorage.getItem('ticketsDateRange')) || getMonthly()
  );
  
  // ビュータイプの変更時にローカルストレージに保存
  useEffect(() => {
    localStorage.setItem('ticketsViewType', viewType);
  }, [viewType]);
  
  // 日付範囲の変更時にローカルストレージに保存
  useEffect(() => {
    localStorage.setItem('ticketsDateRange', JSON.stringify(dateRange));
  }, [dateRange]);
  
  // チケット一覧を取得
  const { data: tickets, isLoading: isLoadingTickets, error: ticketsError } = useQuery(
    ['tickets', dateRange],
    () => fetchTickets({
      start_date: dateRange.startDate ? formatDateForApi(dateRange.startDate) : undefined,
      due_date: dateRange.endDate ? formatDateForApi(dateRange.endDate) : undefined
    }),
    {
      refetchOnWindowFocus: false,
      staleTime: 1000 * 60 * 5 // 5分
    }
  );
  
  // ステータス一覧を取得
  const { data: statuses, isLoading: isLoadingStatuses } = useQuery(
    'statuses',
    fetchStatuses,
    {
      refetchOnWindowFocus: false,
      staleTime: 1000 * 60 * 60 // 1時間
    }
  );
  
  // ユーザー一覧を取得
  const { data: users, isLoading: isLoadingUsers } = useQuery(
    'users',
    fetchUsers,
    {
      refetchOnWindowFocus: false,
      staleTime: 1000 * 60 * 60 // 1時間
    }
  );
  
  // API用の日付フォーマット
  const formatDateForApi = (date) => {
    const d = new Date(date);
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  };
  
  // 日付範囲の変更ハンドラ
  const handleDateRangeChange = (newDateRange) => {
    setDateRange(newDateRange);
  };
  
  // ローディング中
  const isLoading = isLoadingTickets || isLoadingStatuses || isLoadingUsers;
  
  // エラーがある場合
  const error = ticketsError;
  
  return (
    <div className="tickets-page">
      <div className="tickets-header">
        <h1>チケット一覧</h1>
        
        <div className="tickets-actions">
          <Link to="/redmine-ui/tickets/new" className="new-ticket-button">
            <i className="icon-plus"></i>
            新規チケット
          </Link>
          
          <div className="view-toggle">
            <button
              className={`view-toggle-button ${viewType === VIEW_TYPES.LIST ? 'active' : ''}`}
              onClick={() => setViewType(VIEW_TYPES.LIST)}
              title="リスト表示"
            >
              <i className="icon-list"></i>
            </button>
            <button
              className={`view-toggle-button ${viewType === VIEW_TYPES.KANBAN ? 'active' : ''}`}
              onClick={() => setViewType(VIEW_TYPES.KANBAN)}
              title="カンバンボード"
            >
              <i className="icon-board"></i>
            </button>
            <button
              className={`view-toggle-button ${viewType === VIEW_TYPES.ASSIGNEE_KANBAN ? 'active' : ''}`}
              onClick={() => setViewType(VIEW_TYPES.ASSIGNEE_KANBAN)}
              title="担当者別カンバン"
            >
              <i className="icon-user-board"></i>
            </button>
          </div>
        </div>
      </div>
      
      {(viewType === VIEW_TYPES.KANBAN || viewType === VIEW_TYPES.ASSIGNEE_KANBAN) && (
        <DateRangeFilter 
          dateRange={dateRange} 
          onDateRangeChange={handleDateRangeChange} 
        />
      )}
      
      {isLoading ? (
        <div className="loading">読み込み中...</div>
      ) : error ? (
        <div className="error">エラー: {error.message}</div>
      ) : (
        <>
          {viewType === VIEW_TYPES.LIST && (
            <TicketsList tickets={tickets || []} />
          )}
          
          {viewType === VIEW_TYPES.KANBAN && statuses && (
            <KanbanBoard 
              tickets={tickets || []} 
              statuses={statuses} 
              dateRange={dateRange}
            />
          )}
          
          {viewType === VIEW_TYPES.ASSIGNEE_KANBAN && statuses && users && (
            <AssigneeKanbanBoard 
              tickets={tickets || []} 
              statuses={statuses}
              users={users}
              dateRange={dateRange}
            />
          )}
        </>
      )}
    </div>
  );
};

export default TicketsPage;

3.2 TicketsPage CSS

/* src/pages/TicketsPage.css */
.tickets-page {
  padding: 1rem;
}

.tickets-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1.5rem;
}

.tickets-header h1 {
  margin: 0;
  font-size: 1.5rem;
}

.tickets-actions {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.new-ticket-button {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  background-color: #4caf50;
  color: white;
  border: none;
  border-radius: 4px;
  text-decoration: none;
  font-weight: 500;
}

.new-ticket-button:hover {
  background-color: #43a047;
}

.view-toggle {
  display: flex;
  border: 1px solid #ddd;
  border-radius: 4px;
  overflow: hidden;
}

.view-toggle-button {
  padding: 0.5rem;
  border: none;
  background-color: #f5f5f5;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 40px;
}

.view-toggle-button:not(:last-child) {
  border-right: 1px solid #ddd;
}

.view-toggle-button:hover {
  background-color: #e0e0e0;
}

.view-toggle-button.active {
  background-color: #2196f3;
  color: white;
}

.loading {
  padding: 2rem;
  text-align: center;
  color: #666;
}

.error {
  padding: 1rem;
  color: #d32f2f;
  background-color: #ffebee;
  border-radius: 4px;
  margin-bottom: 1rem;
}

4. ユーティリティ関数

4.1 日付ユーティリティ

// src/utils/dateUtils.js
// 日次の日付範囲を取得
export const getDaily = () => {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  
  return {
    startDate: today,
    endDate: today,
    type: 'daily'
  };
};

// 週次の日付範囲を取得
export const getWeekly = () => {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  
  const startOfWeek = new Date(today);
  // 日曜日を週の開始日とする場合
  const dayOfWeek = today.getDay();
  startOfWeek.setDate(today.getDate() - dayOfWeek);
  
  const endOfWeek = new Date(startOfWeek);
  endOfWeek.setDate(startOfWeek.getDate() + 6);
  
  return {
    startDate: startOfWeek,
    endDate: endOfWeek,
    type: 'weekly'
  };
};

// 月次の日付範囲を取得
export const getMonthly = () => {
  const today = new Date();
  
  const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
  
  return {
    startDate: startOfMonth,
    endDate: endOfMonth,
    type: 'monthly'
  };
};

// 日付範囲でチケットをフィルタリング
export const filterTicketsByDateRange = (tickets, dateRange) => {
  if (!dateRange.startDate || !dateRange.endDate || !tickets) {
    return tickets || [];
  }

  const startDate = new Date(dateRange.startDate);
  startDate.setHours(0, 0, 0, 0);
  
  const endDate = new Date(dateRange.endDate);
  endDate.setHours(23, 59, 59, 999);

  return tickets.filter(ticket => {
    const ticketStartDate = ticket.start_date ? new Date(ticket.start_date) : null;
    const ticketDueDate = ticket.due_date ? new Date(ticket.due_date) : null;

    // 開始日も終了日も未設定のチケットは常に表示
    if (!ticketStartDate && !ticketDueDate) {
      return true;
    }

    // チケット開始日 <= フィルター終了日 かつ 
    // (チケット終了日がない または チケット終了日 >= フィルター開始日)
    return (!ticketStartDate || ticketStartDate <= endDate) && 
           (!ticketDueDate || ticketDueDate >= startDate);
  });
};

4.2 カラーユーティリティ

// src/utils/colors.js
// 優先度ごとの色
export const priorityColors = {
  1: '#777777', // 低
  2: '#4caf50', // 通常
  3: '#ff9800', // 高
  4: '#f44336', // 緊急
  5: '#9c27b0'  // 即時
};

// ステータスごとの色
export const statusColors = {
  1: '#42a5f5', // 新規
  2: '#ff9800', // 進行中
  3: '#4caf50', // 解決
  4: '#9c27b0', // フィードバック
  5: '#5e35b1', // 終了
  6: '#9e9e9e'  // 却下
};

5. API連携

5.1 Redmine API サービス

// src/api/redmineApi.js
import axios from 'axios';

// APIのベースURL
const API_BASE_URL = '/api';

// Redmine API用のHTTPクライアント
const redmineClient = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  }
});

// チケット一覧を取得
export const fetchTickets = async (params = {}) => {
  try {
    const response = await redmineClient.get('/issues.json', { 
      params: {
        limit: 100,
        ...params
      }
    });
    return response.data.issues;
  } catch (error) {
    console.error('Failed to fetch tickets:', error);
    throw new Error('チケットの取得に失敗しました');
  }
};

// ステータス一覧を取得
export const fetchStatuses = async () => {
  try {
    const response = await redmineClient.get('/issue_statuses.json');
    return response.data.issue_statuses;
  } catch (error) {
    console.error('Failed to fetch statuses:', error);
    throw new Error('ステータスの取得に失敗しました');
  }
};

// ユーザー一覧を取得
export const fetchUsers = async () => {
  try {
    const response = await redmineClient.get('/users.json', {
      params: { limit: 100 }
    });
    return response.data.users;
  } catch (error) {
    console.error('Failed to fetch users:', error);
    throw new Error('ユーザーの取得に失敗しました');
  }
};

// チケットを更新
export const updateTicket = async (id, data) => {
  try {
    const response = await redmineClient.put(`/issues/${id}.json`, {
      issue: data
    });
    return response.data;
  } catch (error) {
    console.error(`Failed to update ticket #${id}:`, error);
    throw new Error('チケットの更新に失敗しました');
  }
};

6. ルーティング設定

// src/App.jsx (部分)
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import MainLayout from './layouts/MainLayout';
import TicketsPage from './pages/TicketsPage';
import TicketDetailPage from './pages/TicketDetailPage';
import NewTicketPage from './pages/NewTicketPage';
// その他のインポート

// React Query クライアント
const queryClient = new QueryClient();

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <Routes>
          <Route path="/redmine-ui" element={<MainLayout />}>
            <Route index element={<Navigate to="/redmine-ui/dashboard" replace />} />
            <Route path="dashboard" element={<DashboardPage />} />
            <Route path="tickets" element={<TicketsPage />} />
            <Route path="tickets/new" element={<NewTicketPage />} />
            <Route path="tickets/:id" element={<TicketDetailPage />} />
            {/* 他のルート */}
          </Route>
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  );
};

export default App;

7. 実装上の注意点

  1. React版のライブラリを使用:

    • ドラッグ&ドロップ: @hello-pangea/dnd (react-beautiful-dndの代替)
    • API通信: react-query
    • ルーティング: react-router-dom
  2. パフォーマンス最適化:

    • useMemoを使用したデータ計算の最適化
    • 適切なキャッシュ設定による不要な再フェッチの防止
    • 大量データ表示時の仮想化対応準備
  3. エラーハンドリング:

    • API通信時のエラーキャッチと表示
    • ユーザーフレンドリーなエラーメッセージ
  4. レスポンシブデザイン:

    • 横スクロール対応のテーブルレイアウト
    • モバイルでも使いやすいUI

8. 残作業

  1. プロキシ設定の追加 (APIリクエストをRedmineに転送)
  2. アイコンフォントの導入
  3. ドラッグ&ドロップ機能の完成
  4. 詳細なテスト

以上が実装の主要部分です。これらのコードをtask2.call2arm.comの環境に統合することで、要件にある機能を実現できます。

Redmine Admin さんが5日前に更新

  • ステータス新規 から 解決 に変更

実装コードを作成しました。主要なコンポーネントと必要なファイルの構造を提供しています。このコードをtask2.call2arm.com環境に統合することで、要件を満たすことができます。テスト段階に進みます。

他の形式にエクスポート: Atom PDF