2025年7月7日

Backlog 生産性可視化 ツール機能

機能概要

Backlog REST API v2 から課題を取得し、完了タスクを Excel 形式(.xlsx)でダウンロードできる機能を追加しました。

UI(/backlog) ──▶ GET /api/export-backlog ──▶ Backlog API (/issues)
           ▲                                              │
           └────────────── Excel ファイル (.xlsx) ─────────┘

ディレクトリ構成

| 追加/変更ファイル                                       | 役割                              |
| ------------------------------------------------------- | --------------------------------- |
| `app/_features/backlog/funcs.ts`                        | Backlog API 呼び出し & データ整形 |
| `app/_features/backlog/type.ts`                         | Backlog Issue 型定義              |
| `app/_features/backlog/components/task-table/`          | 課題一覧テーブル UI               |
| `app/_features/backlog/components/export-excel-button/` | Excel ダウンロードボタン UI       |
| `app/api/export-backlog/route.ts`                       | Excel 生成 & ダウンロード API     |
| `app/backlog/page.tsx`                                  | 機能を束ねるページ                |
| `package.json`                                          | 依存に `exceljs` を追加           |

コア実装

1. Backlog から課題を取得する

const BASE_URL = `https://${BACKLOG_SPACE_ID}.backlog.com/api/v2`

async function backlogFetch<T>(
  endpoint: string,
  params?: Record<string, unknown>
): Promise<T> {
  const url = new URL(`${BASE_URL}${endpoint}`)
  const searchParams = new URLSearchParams()

  // API keyはパラメーターで渡す
  searchParams.append('apiKey', BACKLOG_API_KEY as string)
  url.search = searchParams.toString()

  const res = await fetch(url.toString())
  if (!res.ok) {
    throw new Error(`Backlog API error: ${res.status} ${res.statusText}`)
  }

  return (await res.json()) as T
}

async function fetchBacklogTasks(): Promise<BacklogTask[]> {
  // 課題取得一覧
  // @see: https://developer.nulab.com/ja/docs/backlog/api/2/get-issue-list/
  // マイルストーン
  // @see: https://developer.nulab.com/ja/docs/backlog/api/2/get-version-milestone-list/
  const issues = await backlogFetch<BIssues[]>('/issues')

  const tasks: BacklogTask[] = []

  for (const issue of issues) {
    tasks.push({
      id: issue.id,
      summary: issue.summary,
      assigneeName: issue.assignee?.name,
      statusName: issue.status?.name,
      created: issue.created,
      parentIssueId: issue.parentIssueId,
    })
  }

  return tasks
}

// page.tsx
import { BacklogExportButton } from '@/_features/backlog/components/export-excel-button'
import { TaskTable } from '@/_features/backlog/components/task-table'

type BacklogTask = {
  id: number
  summary: string
  assigneeName: string
  statusName: string
  created: string
  parentIssueId: number | null
}

export default async function BacklogPage() {
  let tasks: BacklogTask[] = []
  let error: string | null = null

  try {
    tasks = await fetchBacklogTasks()
  } catch (e) {
    error = (e as Error).message
  }

  return (
    <main className="p-4">
      <h1 className="mb-4 text-2xl font-bold">Backlog タスク一覧</h1>
      <div className="mb-4">
        <BacklogExportButton />
      </div>
      {error ? (
        <p className="text-red-500">{error}</p>
      ) : (
        <table className="min-w-full text-left">
          <thead className="border-b">
            <tr>
              <th className="px-2 py-1">担当者名</th>
              <th className="px-2 py-1">件名(Issue)</th>
              <th className="px-2 py-1">Created</th>
              <th className="px-2 py-1">ステータス</th>
              <th className="px-2 py-1">子課題</th>
            </tr>
          </thead>
          <tbody>
            {tasks.map((t) => (
              <tr key={t.id} className="border-b last:border-b-0">
                <td className="px-2 py-1">{t.assigneeName}</td>
                <td className="px-2 py-1">{t.summary}</td>
                <td className="px-2 py-1">{t.created}</td>
                <td className="px-2 py-1">{t.statusName}</td>
                <td className="px-2 py-1">{t.parentIssueId}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </main>
  )
}

issuesAPIを叩いたレスポンスの型

export interface BIssues {
  id: number
  projectId: number
  issueKey: string
  keyId: number
  issueType: {
    id: number
    projectId: number
    name: string
    color: string
    displayOrder: number
  }
  /** 件名 */
  summary: string
  description: string
  resolution: string | null
  priority: {
    id: number
    name: string
  }
  /** ステータス */
  status: {
    // ステータスのID(完了の場合は4のような数値)
    id: number
    projectId: number
    // ステータスの名前(完了など)
    name: string
    color: string
    displayOrder: number
  }
  /* 担当者*/
  assignee: {
    id: number
    userId: string
    name: string
    roleType: number
    lang: string | null
    nulabAccount: {
      nulabId: string
      name: string
      uniqueId: string
    }
    mailAddress: string
    lastLoginTime: string
  }
  category: string[]
  versions: string[]
  milestone: string[]
  /** 開始日 */
  startDate: string | null
  /** 完了日 */
  completed: string | null
  /** 期限日 */
  dueDate: string | null
  /** 見積もり時間 */
  estimatedHours: number | null
  /** 実績時間 */
  actualHours: number | null
  /** 親課題ID */
  parentIssueId: number | null
  createdUser: {
    id: number
    userId: string
    name: string
    roleType: number
    lang: string | null
  }
  created: string
  updatedUser: {
    id: number
    userId: string
    name: string
    roleType: number
    lang: string | null
  }
  updated: string
  customFields: any[]
  attachments: any[]
  sharedFiles: any[]
  stars: any[]
}
// 16:44:app/_features/backlog/funcs.ts
export async function fetchBacklogTasks(): Promise<BacklogTask[]> {
  const issues = await backlogFetch<BIssues[]>('/issues')

  return issues.map((issue) => ({
    id: issue.id,
    summary: issue.summary,
    assigneeName: issue.assignee?.name,
    statusName: issue.status?.name,
    created: issue.created,
    parentIssueId: issue.parentIssueId,
  }))
}
  • backlogFetch が環境変数 BACKLOG_SPACE_IDBACKLOG_API_KEY を用いて REST API を実行。
  • 取得した 生 Issue表示用に絞った BacklogTask モデルへ変換しています。

必要な環境変数

BACKLOG_SPACE_ID=example      # サブドメイン部
BACKLOG_API_KEY=xxxxxxxxxxx   # 発行した個人 API キー

2. Excel を生成して返す API

// 9:38:app/api/export-backlog/route.ts
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet("Backlog Report")

sheet.columns = [
  { header: "担当者名", key: "assigneeName", width: 20 },
  { header: "件名(Issue)", key: "summary", width: 40 },
  { header: "Created", key: "created", width: 25 },
  { header: "ステータス", key: "statusName", width: 25 },
  { header: "ParentIssueId", key: "parentIssueId", width: 15 },
]

// tasks.forEach(...)   // ← 行追加はここ(今はコメントアウト)
const buffer = await workbook.xlsx.writeBuffer()
return new Response(buffer, {
  headers: {
    "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "Content-Disposition": "attachment; filename=backlog_report.xlsx",
  },
})
  • ExcelJS を使用してワークブックを生成。列定義後に行を追加し、バッファとしてレスポンスに流します。
  • Next.js Route Handler なので fetchBacklogTasks() を呼んで即座にファイルダウンロード。

3. ダウンロードボタン

// 3:8:app/_features/backlog/components/export-excel-button/hooks.ts
export function useBacklogExport() {
  return useCallback(() => {
    const link = document.createElement('a')
    link.href = '/api/export-backlog'
    link.click()
  }, [])
}
  • クリック時に 仮想リンク を生成し /api/export-backlog にリダイレクト → ブラウザが自動ダウンロード。

このフックを使った UI コンポーネント

// 10:16:app/_features/backlog/components/export-excel-button/index.tsx
<button onClick={handleExport}>📥 Excel出力(完了課題)</button>

4. 一覧テーブル

// 14:31:app/_features/backlog/components/task-table/index.tsx
<tbody>
  {rows.map((t) => (
    <tr key={t.id}>
      <td>{t.assigneeName}</td>
      <td>{t.summary}</td>
      <td>{t.created}</td>
      <td>{t.statusName}</td>
      <td>{t.parentIssueId}</td>
    </tr>
  ))}
</tbody>
  • useTaskRowstasks をメモ化し、シンプルに描画。

今後の TODO

  • 行追加処理(sheet.addRow(...))のコメントアウトを解除して実データを書き込む
  • BACKLOG_PROJECT_IDstatusId=4(完了)の絞り込みパラメータ追加
  • 日付フォーマットやリードタイムの算出などレポート強化

以上で Backlog ツール機能の概要と実装ポイントの説明を終わります。