実装コード続き¶
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. 実装上の注意点¶
-
React版のライブラリを使用:
- ドラッグ&ドロップ: @hello-pangea/dnd (react-beautiful-dndの代替)
- API通信: react-query
- ルーティング: react-router-dom
-
パフォーマンス最適化:
- useMemoを使用したデータ計算の最適化
- 適切なキャッシュ設定による不要な再フェッチの防止
- 大量データ表示時の仮想化対応準備
-
エラーハンドリング:
- API通信時のエラーキャッチと表示
- ユーザーフレンドリーなエラーメッセージ
-
レスポンシブデザイン:
- 横スクロール対応のテーブルレイアウト
- モバイルでも使いやすいUI
8. 残作業¶
- プロキシ設定の追加 (APIリクエストをRedmineに転送)
- アイコンフォントの導入
- ドラッグ&ドロップ機能の完成
- 詳細なテスト
以上が実装の主要部分です。これらのコードをtask2.call2arm.comの環境に統合することで、要件にある機能を実現できます。