フロントエンドにおけるデータアップロード時のアイテム追跡機能(履歴表示)について

スマートコンテンツ事業部の兒玉です。

直近バックエンド&フロントエンドのデータ取り込み部分の改修を担当し、その中で面白い学びがあったので共有したいと思います。 自身が学生の時に知っていると嬉しいなと感じることがあったので少しでも学びの糧になれば幸いです。

※この記事は「エムティーアイ Blog Summer 2025」の 8/27(水) 分の記事です。
※本記事の一部内容(コード例、解説文の構成など)はAIを活用して作成しています。
※本記事のデモアプリケーションは教育向けに作成したもので、業務との関わりは一切ありません。言語/技術スタック全て個人の趣味で構成しています。

この記事の対象者

  • 学生
  • 初学者
  • 駆け出しエンジニア

はじめに

皆さんは、Webアプリで「処理中です...」と表示されたまま何分も待たされた経験はありませんか?

大量データのアップロード処理や複雑な計算処理など、時間のかかる処理をユーザーに快適に体験してもらうには一体どうすればよいのでしょうか?

処理状況が見えないと、ユーザーは「本当に動いているのか?」「いつ終わるのか?」と不安になってしまいます。そこで重要になるのがリアルタイム処理状況監視の実装です。

今回は、業務システムでよく見られるアップロード処理の状況監視機能を、Next.js 15を使って実装したデモアプリケーションを通して紹介します。

作ったもの:データアップロード監視システム

今回、理解を深めるにあたり「データアップロード監視システム」のデモアプリケーションを作成しました。

デモアプリケーション画面

できること

  • リアルタイム状況監視 - 処理中のアップロードを2-3秒間隔で自動更新
  • 詳細な履歴管理 - 作成日時・完了日時を含む全アップロード履歴
  • 手動操作 - 処理中の状況を手動で完了/エラーに変更可能
  • 正確な時刻表示 - UTC保存・JST表示で国際化対応

使用技術

  • Next.js 15 (App Router)
  • React + TypeScript
  • SQLite (better-sqlite3)
  • Tailwind CSS

実は、この程度のシンプルな機能でも、実装してみると意外と考慮すべき点が多いです。 開発していく中で開発者が見落としがちな点も含めて紹介します。

ディレクトリ構成

完成系のディレクトリ構成はこのようになっています。 よくあるNext.js 15のApp Routerを活用した、APIルートとページコンポーネントを分離した構成になっています。

status-monitor-app/
├── src/
│   ├── app/
│   │   ├── api/upload/          # API エンドポイント
│   │   │   ├── status/route.ts  # 状況監視・作成API
│   │   │   └── history/route.ts # 履歴取得・削除API
│   │   ├── layout.tsx           # アプリケーションレイアウト
│   │   ├── page.tsx            # メインページ
│   │   └── globals.css         # グローバルスタイル
│   ├── components/
│   │   ├── StatusMonitor.tsx    # リアルタイム監視コンポーネント
│   │   ├── UploadHistory.tsx    # 履歴表示コンポーネント
│   │   ├── CreateUpload.tsx     # アップロード作成フォーム
│   │   └── ClientOnly.tsx      # SSR対応ラッパー
│   ├── lib/
│   │   └── database.ts          # データベース操作・Repository
│   └── utils/
│       └── dateUtils.ts         # 日時変換ユーティリティ
├── uploads.db                   # SQLite データベースファイル
├── package.json
└── next.config.ts

DB構成

今回のデモアプリケーションでは、SQLiteを使用してシンプルながら実用的なデータベース設計を行いました。

テーブル定義 (uploads)

CREATE TABLE IF NOT EXISTS uploads (
  id INTEGER PRIMARY KEY AUTOINCREMENT,  -- 自動発番ID
  filename TEXT NOT NULL,                 -- アップロードファイル名
  status INTEGER NOT NULL DEFAULT 0,      -- 処理ステータス (0:処理中, 1:部分完了, 2:完了, 3:エラー )
  message TEXT NOT NULL DEFAULT '',       -- 処理状況メッセージ
  created_at TEXT NOT NULL,               -- 作成日時 (UTC)
  updated_at TEXT NOT NULL,               -- 更新日時 (UTC)
  completed_at TEXT                       -- 完了日時 (UTC, NULL可)
);

-- パフォーマンス向上のためのインデックス
CREATE INDEX IF NOT EXISTS idx_uploads_status ON uploads(status);
CREATE INDEX IF NOT EXISTS idx_uploads_created_at ON uploads(created_at);

ステータス管理の設計ポイントは以下です。

  • 数値型ステータスを使用: 0=処理中、1=部分完了、2=完了、3=エラー(拡張しやすい)
  • UTC統一: 全ての日時をUTCで保存し、表示時にJSTに変換
  • メッセージ分離: ステータスとは別に詳細メッセージを保持できる
  • 完了日時: 処理中はNULL、完了時に設定する

処理ステータス定義は以下のように定義しました。

Status Name Description
0 pending 処理中
1 partial 部分完了
2 complete 完了
3 error エラー

処理の流れ

具体的な処理フローは以下の通りです

  1. アップロード作成時
    CreateUpload → POST /api/upload/status → Database.create() → 自動処理開始(setTimeout) → ステータス更新

  2. リアルタイム監視時
    StatusMonitor → GET /api/upload/status (2.5秒間隔) → Database.findProcessing() → 処理中データ取得

  3. 履歴表示時
    UploadHistory → GET /api/upload/history → Database.findLatest() → UTC→JST変換して表示

アップロード処理では、後のリアルタイム監視に向けて、いくつか考慮が必要な事項があります。

  1. アイテムを追跡するためのID発行 (IDは自動発番でダブりがないように対応)
  2. ファイル名の保持
  3. 処理が開始されたことを示すステータス登録 (アップロード時は[pending=0]で登録)

それぞれサンプルを確認します。

フロントエンド

フロントエンドは3つの主要コンポーネントで構成しました。

1. CreateUpload - アップロード作成フォームコンポーネント

ユーザーが新しいアップロード処理を開始するためのフォームコンポーネントです。
ここではデモアプリのために「ランダムファイル名生成機能」を実装しており、そのファイル名を元に、以下のリクエストをバックエンドへ投げDBへ登録しています。

バックエンドへ投げるリクエスト

    const response = await fetch('/api/upload/status', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        upload_type: uploadType,
        file_name: fileName,
        status: 0, // pending で開始
        final_status: finalStatus,
        processing_time: processingTime,
      }),

フロント部分全体のコード (ここをクリックすると展開されます)

// src/components/CreateUpload.tsx
'use client';

import { useState } from 'react';

interface CreateUploadProps {
  onUploadCreated?: () => void; // 親コンポーネントへの通知コールバック
}

export default function CreateUpload({ onUploadCreated }: CreateUploadProps) {
  // ===== ローカル状況管理 =====
  const [uploadType, setUploadType] = useState('');        // アップロード種別
  const [fileName, setFileName] = useState('');            // ファイル名
  const [message, setMessage] = useState('');              // カスタムメッセージ
  const [finalStatus, setFinalStatus] = useState(2);       // 最終ステータス(2=成功)
  const [processingTime, setProcessingTime] = useState(20); // 処理時間(秒)
  const [isSubmitting, setIsSubmitting] = useState(false);  // 送信中フラグ

  // ===== ランダムファイル名生成機能 =====
  const generateRandomFileName = () => {
    const prefixes = ['data', 'sample', 'test', 'export', 'report', 'batch'];
    const suffixes = ['csv', 'xlsx', 'json', 'xml'];

    // 現在日付をyyyymmdd形式で取得
    const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');

    // 3桁の乱数を生成(001-999)
    const randomNum = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
    const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
    const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];

    // プレフィックス_日付_乱数.拡張子 の形式で生成
    return `${prefix}_${timestamp}_${randomNum}.${suffix}`;
  };

  // ===== フォーム送信処理 =====
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // 入力値バリデーション
    if (!uploadType || !fileName) {
      alert('アップロード種別とファイル名を入力してください');
      return;
    }

    setIsSubmitting(true); // ボタンを無効化

    try {
      // バックエンドAPIにPOSTリクエスト
      const response = await fetch('/api/upload/status', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          upload_type: uploadType,
          file_name: fileName,
          status: 0,                   // 必ずpending(処理中)で開始
          message: message || '処理を開始しました', // カスタムメッセージまたはデフォルト
          final_status: finalStatus,   // 処理完了時に設定するステータス
          processing_time: processingTime, // シミュレーション用の処理時間
        }),
      });

      const result = await response.json();

      if (result.success) {
        // 成功時:フォームリセットと親コンポーネントに通知
        setUploadType('');
        setFileName('');
        setMessage('');
        setFinalStatus(2);
        setProcessingTime(20);

        // 親コンポーネントに通知
        onUploadCreated?.(); // オプショナルチェーンで安全に呼び出し

        alert(`アップロードが作成されました(${processingTime}秒後に完了予定)`);
      } else {
        // APIエラー処理
        alert('アップロードの作成に失敗しました: ' + result.error);
      }
    } catch (error) {
      console.error('Upload request failed:', error);
      alert('アップロードの作成に失敗しました');
    } finally {
      setIsSubmitting(false); // 送信状態を解除
    }
  };

  // ===== レンダリング =====
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <h2 className="text-xl font-semibold text-gray-800 mb-4">新規アップロード作成</h2>

      <form onSubmit={handleSubmit} className="space-y-4">
        {/* アップロード種別選択 */}
        <div>
          <label htmlFor="uploadType" className="block text-sm font-medium text-gray-700 mb-1">
            アップロード種別
          </label>
          <select
            id="uploadType"
            value={uploadType}
            onChange={(e) => setUploadType(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            required
          >
            <option value="">選択してください</option>
            <option value="データタイプA">データタイプA</option>
            <option value="データタイプB">データタイプB</option>
            <option value="データタイプC">データタイプC</option>
          </select>
        </div>

        {/* ファイル名入力(ランダム生成ボタン付き) */}
        <div>
          <label htmlFor="fileName" className="block text-sm font-medium text-gray-700 mb-1">
            ファイル名
          </label>
          <div className="flex space-x-2">
            <input
              type="text"
              id="fileName"
              value={fileName}
              onChange={(e) => setFileName(e.target.value)}
              className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
              placeholder="sample_data.csv"
              required
            />
            <button
              type="button"
              onClick={() => setFileName(generateRandomFileName())}
              className="px-3 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 text-sm"
            >
              ランダム
            </button>
          </div>
        </div>

        {/* 完了後の状況設定 */}
        <div>
          <label htmlFor="finalStatus" className="block text-sm font-medium text-gray-700 mb-1">
            完了後の状況
          </label>
          <select
            id="finalStatus"
            value={finalStatus}
            onChange={(e) => setFinalStatus(Number(e.target.value))}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          >
            <option value={2}>成功</option>
            <option value={1}>一部成功</option>
            <option value={3}>エラー</option>
          </select>
        </div>

        {/* 処理時間設定 */}
        <div>
          <label htmlFor="processingTime" className="block text-sm font-medium text-gray-700 mb-1">
            処理時間
          </label>
          <select
            id="processingTime"
            value={processingTime}
            onChange={(e) => setProcessingTime(Number(e.target.value))}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          >
            <option value={20}>20秒</option>
            <option value={60}>60秒</option>
            <option value={120}>120秒</option>
          </select>
        </div>

        {/* カスタムメッセージ */}
        <div>
          <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
            メッセージ(任意)
          </label>
          <textarea
            id="message"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            rows={3}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="補足情報があれば入力してください"
          />
        </div>

        {/* 送信ボタン */}
        <button
          type="submit"
          disabled={isSubmitting}
          className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isSubmitting ? '作成中...' : 'アップロード作成'}
        </button>
      </form>
    </div>
  );
}

2. StatusMonitor - リアルタイム監視コンポーネント

このコンポーネントでは、処理中のアップロードをポーリング (一定間隔での問い合わせ) で監視し、リアルタイムで状況を表示します。

処理が完了したアイテムは自動的に表示から消えるため、現在進行形で何を処理しているのか、一目で把握できます。

以下はポーリング監視部分のコードです。 流れは単純で、以下処理フローでデータを表示しています。

  1. DBからstatus状況を取得
  2. 処理中のアイテムがあるか判別(なければ停止)
  3. あればアイテム表示
const StatusMonitor = ({ pollingInterval = 2500 }: StatusMonitorProps) => {
  const [processingUploads, setProcessingUploads] = useState<UploadHistory[]>([]);
  const [isPolling, setIsPolling] = useState(false);

  const checkStatus = async () => {
    try {
      const response = await fetch('/api/upload/status');
      const data = await response.json();

      if (data.success) {
        setProcessingUploads(data.data || []);

        // 処理中のものがなければポーリング停止
        if (!data.hasProcessing) {
          setIsPolling(false);
        }
      }
    } catch (error) {
      console.error('Status check failed:', error);
    }
  };

  useEffect(() => {
    if (!isPolling) return;

    checkStatus();
    const interval = setInterval(checkStatus, pollingInterval);
    return () => clearInterval(interval);
  }, [isPolling, pollingInterval]);
};

フロント部分全体のコード (ここをクリックすると展開されます)

// src/components/StatusMonitor.tsx
'use client';

import { useState, useEffect } from 'react';
import { formatUTCToJST, formatJST } from '@/utils/dateUtils';
import ClientOnly from './ClientOnly';

interface UploadHistory {
  id: number;
  upload_type: string;
  file_name: string;
  status: number;
  message?: string;          // オプショナル
  completed_at?: string;     // オプショナル
  created_at: string;
  updated_at: string;
}

interface StatusMonitorProps {
  uploadType?: string;       // 特定タイプのみ監視する場合
  pollingInterval?: number;  // ポーリング間隔(ミリ秒、デフォルト2秒)
}

// ===== ステータス表示用ヘルパー関数 =====
const getStatusText = (status: number): string => {
  switch (status) {
    case 0: return '処理中';
    case 1: return '一部成功';
    case 2: return '全て成功';
    case 3: return 'エラー';
    default: return '不明';
  }
};

const getStatusColor = (status: number): string => {
  switch (status) {
    case 0: return 'text-yellow-600 bg-yellow-50 border-yellow-200';
    case 1: return 'text-orange-600 bg-orange-50 border-orange-200';
    case 2: return 'text-green-600 bg-green-50 border-green-200';
    case 3: return 'text-red-600 bg-red-50 border-red-200';
    default: return 'text-gray-600 bg-gray-50 border-gray-200';
  }
};

export default function StatusMonitor({ uploadType, pollingInterval = 2000 }: StatusMonitorProps) {
  // ===== ローカル状況管理 =====
  const [processingUploads, setProcessingUploads] = useState<UploadHistory[]>([]);
  const [isPolling, setIsPolling] = useState(false);    // 手動制御のポーリングフラグ
  const [lastChecked, setLastChecked] = useState<Date | null>(null);

  // ===== ステータス確認API呼び出し =====
  const checkStatus = async () => {
    try {
      // アップロードタイプが指定されている場合はクエリパラメータに含める
      const params = uploadType ? `?type=${encodeURIComponent(uploadType)}` : '';
      const response = await fetch(`/api/upload/status${params}`);
      const result = await response.json();

      if (result.success) {
        // 重要: 全てのアップロード状況を取得(処理中以外も含む)
        setProcessingUploads(result.data);
        setLastChecked(new Date());
      }
    } catch (error) {
      console.error('Status check failed:', error);
      // エラー時もポーリングは継続(ネットワーク一時的な問題の可能性)
    }
  };

  // ===== useEffect: ポーリング制御 =====
  useEffect(() => {
    let intervalId: NodeJS.Timeout;

    if (isPolling) {
      // 初回実行
      checkStatus();

      // 定期実行の設定
      intervalId = setInterval(checkStatus, pollingInterval);
    }

    // クリーンアップ関数
    return () => {
      if (intervalId) {
        clearInterval(intervalId);
      }
    };
    // eslint-disable-next-line を使用して依存配列の警告を抑制
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isPolling, uploadType, pollingInterval]);

  // ===== レンダリング =====
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      {/* ヘッダー部分 */}
      <div className="flex items-center justify-between mb-4">
        <h2 className="text-xl font-semibold text-gray-800">
          アップロード状況監視
          {/* アップロードタイプ表示 */}
          {uploadType && <span className="text-sm text-gray-600 ml-2">({uploadType})</span>}
        </h2>

        {/* 制御ボタン */}
        <div className="flex items-center space-x-2">
          {/* 監視開始/停止ボタン */}
          <button
            onClick={() => setIsPolling(!isPolling)}
            className={`px-4 py-2 rounded-md text-sm font-medium ${
              isPolling
                ? 'bg-red-600 text-white hover:bg-red-700'
                : 'bg-blue-600 text-white hover:bg-blue-700'
            }`}
          >
            {isPolling ? '監視停止' : '監視開始'}
          </button>

          {/* 手動更新ボタン */}
          <button
            onClick={checkStatus}
            disabled={isPolling}  // 監視中は無効化
            className="px-4 py-2 rounded-md text-sm font-medium bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            手動更新
          </button>
        </div>
      </div>

      {/* 最終確認時刻表示 */}
      {lastChecked && (
        <div className="text-sm text-gray-500 mb-4">
          最終確認: {formatJST(lastChecked)}
        </div>
      )}

      {/* アップロード一覧 */}
      <div className="space-y-3">
        {processingUploads.length === 0 ? (
          <div className="text-center py-8 text-gray-500">
            {isPolling ? '処理中のアップロードはありません' : '監視を開始してください'}
          </div>
        ) : (
          processingUploads.map((upload) => (
            <div
              key={upload.id}
              className={`border rounded-lg p-4 ${getStatusColor(upload.status)}`}
            >
              <div className="flex items-center justify-between">
                {/* アップロード情報 */}
                <div>
                  <h3 className="font-medium">{upload.file_name}</h3>
                  <p className="text-sm opacity-75">種別: {upload.upload_type}</p>
                  {/* メッセージがある場合のみ表示 */}
                  {upload.message && (
                    <p className="text-sm opacity-75 mt-1">{upload.message}</p>
                  )}
                </div>

                {/* ステータス表示 */}
                <div className="text-right">
                  <span className="inline-block px-2 py-1 text-xs font-medium rounded-full bg-white bg-opacity-50">
                    {getStatusText(upload.status)}
                  </span>
                  <p className="text-xs opacity-75 mt-1">
                    {/* 重要: UTC→JST変換をClientOnlyで包む(SSR対応) */}
                    <ClientOnly fallback="読み込み中...">
                      {formatUTCToJST(upload.created_at)}
                    </ClientOnly>
                  </p>
                </div>
              </div>
            </div>
          ))
        )}
      </div>

      {/* 監視中インジケータ */}
      {isPolling && (
        <div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
          <div className="flex items-center">
            <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
            <span className="text-sm text-blue-700">
              {pollingInterval / 1000}秒間隔で監視中...
            </span>
          </div>
        </div>
      )}
    </div>
  );
}

3. UploadHistory - 履歴表示

全アップロード履歴を表示し、アイテム状況を確認できます。 この機能は以下処理の流れで実装しています。

  1. 自動更新の制御: useEffectで2秒間隔の自動更新を管理
  2. 履歴データ取得: /api/upload/history?limit=${limit} から指定件数の履歴を取得
  3. リアルタイム表示: 取得したデータをテーブル形式で一覧表示
  4. 手動操作: 処理中のアイテムに対して「完了」「エラー」ボタンを表示
  5. 即座の反映: ステータス変更後に履歴リストを再取得して最新状況を表示
// ===== 履歴データ取得の核となる部分 =====
const fetchHistories = async () => {
  setLoading(true);
  try {
    // limitパラメータで取得件数を制限
    const response = await fetch(`/api/upload/history?limit=${limit}`);
    const result = await response.json();

    if (result.success) {
      setHistories(result.data);
      setLastUpdated(new Date());
    }
  } catch (error) {
    console.error('Failed to fetch histories:', error);
  } finally {
    setLoading(false);
  }
};

// ===== 自動更新制御の核となる部分 =====
useEffect(() => {
  let intervalId: NodeJS.Timeout;

  if (isAutoRefresh) {
    fetchHistories();  // 初回実行
    intervalId = setInterval(fetchHistories, 2000);  // 2秒間隔
  }

  return () => {
    if (intervalId) clearInterval(intervalId);
  };
}, [isAutoRefresh, limit]); // limitが変更されたら再実行

フロント部分全体のコード (ここをクリックすると展開されます)

// src/components/UploadHistory.tsx
'use client';

import { useState, useEffect } from 'react';
import { formatUTCToJST, formatJST } from '@/utils/dateUtils';
import ClientOnly from './ClientOnly';

interface UploadHistory {
  id: number;
  upload_type: string;
  file_name: string;
  status: number;
  message?: string;         // オプショナル
  completed_at?: string;    // オプショナル
  created_at: string;
  updated_at: string;
}

// ===== ステータス表示用ヘルパー関数 =====
const getStatusText = (status: number): string => {
  switch (status) {
    case 0: return '処理中';
    case 1: return '一部成功';
    case 2: return '全て成功';
    case 3: return 'エラー';
    default: return '不明';
  }
};

const getStatusColor = (status: number): string => {
  switch (status) {
    case 0: return 'text-yellow-600 bg-yellow-100';
    case 1: return 'text-orange-600 bg-orange-100';
    case 2: return 'text-green-600 bg-green-100';
    case 3: return 'text-red-600 bg-red-100';
    default: return 'text-gray-600 bg-gray-100';
  }
};

export default function UploadHistory() {
  // ===== ローカル状況管理 =====
  const [histories, setHistories] = useState<UploadHistory[]>([]);
  const [loading, setLoading] = useState(false);            // ローディング状態
  const [limit, setLimit] = useState(10);                   // 表示件数制限
  const [isAutoRefresh, setIsAutoRefresh] = useState(true); // 自動更新ON/OFF
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

  // ===== 履歴データ取得 =====
  const fetchHistories = async () => {
    setLoading(true);
    try {
      // limitパラメータで取得件数を制限
      const response = await fetch(`/api/upload/history?limit=${limit}`);
      const result = await response.json();

      if (result.success) {
        setHistories(result.data);
        setLastUpdated(new Date());
      }
    } catch (error) {
      console.error('Failed to fetch histories:', error);
    } finally {
      setLoading(false);
    }
  };

  // ===== useEffect: 自動更新制御 =====
  useEffect(() => {
    let intervalId: NodeJS.Timeout;

    if (isAutoRefresh) {
      // 初回実行
      fetchHistories();

      // 2秒間隔で自動更新
      intervalId = setInterval(fetchHistories, 2000);
    }

    return () => {
      if (intervalId) {
        clearInterval(intervalId);
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isAutoRefresh, limit]); // limitが変更されたら再実行

  // ===== 手動ステータス更新 =====
  const updateStatus = async (id: number, status: number, message?: string) => {
    try {
      const response = await fetch(`/api/upload/status/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status, message }),
      });

      const result = await response.json();

      if (result.success) {
        // 更新成功時:履歴リストを再取得
        fetchHistories();
      }
    } catch (error) {
      console.error('Failed to update status:', error);
    }
  };

  // ===== 全履歴削除 =====
  const clearAllHistories = async () => {
    if (!confirm('全ての履歴を削除しますか?')) {
      return;
    }

    try {
      const response = await fetch('/api/upload/history', {
        method: 'DELETE',
      });

      const result = await response.json();

      if (result.success) {
        setHistories([]); // ローカル状況もクリア
      }
    } catch (error) {
      console.error('Failed to clear histories:', error);
    }
  };

  // ===== レンダリング =====
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      {/* ヘッダー部分 */}
      <div className="flex items-center justify-between mb-4">
        <div>
          <h2 className="text-xl font-semibold text-gray-800">アップロード履歴</h2>
          {/* 最終更新時刻表示 */}
          {lastUpdated && (
            <ClientOnly fallback={<p className="text-sm text-gray-500 mt-1">最終更新: 読み込み中...</p>}>
              <p className="text-sm text-gray-500 mt-1">
                最終更新: {formatJST(lastUpdated)}
              </p>
            </ClientOnly>
          )}
        </div>

        {/* 制御ボタン群 */}
        <div className="flex items-center space-x-2">
          {/* 自動更新切り替えボタン */}
          <button
            onClick={() => setIsAutoRefresh(!isAutoRefresh)}
            className={`px-3 py-1 rounded text-sm font-medium ${
              isAutoRefresh
                ? 'bg-green-600 text-white hover:bg-green-700'
                : 'bg-gray-600 text-white hover:bg-gray-700'
            }`}
          >
            {isAutoRefresh ? '自動更新中' : '自動更新停止'}
          </button>

          {/* 表示件数選択 */}
          <select
            value={limit}
            onChange={(e) => setLimit(Number(e.target.value))}
            className="px-3 py-1 border border-gray-300 rounded-md text-sm"
          >
            <option value={10}>10件</option>
            <option value={20}>20件</option>
            <option value={50}>50件</option>
          </select>

          {/* 手動更新ボタン */}
          <button
            onClick={fetchHistories}
            disabled={loading}
            className="px-4 py-2 rounded-md text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? '読込中...' : '手動更新'}
          </button>

          {/* 全削除ボタン */}
          <button
            onClick={clearAllHistories}
            className="px-4 py-2 rounded-md text-sm font-medium bg-red-600 text-white hover:bg-red-700"
          >
            全削除
          </button>
        </div>
      </div>

      {/* 履歴テーブル */}
      <div className="overflow-x-auto">
        <table className="min-w-full table-auto">
          <thead>
            <tr className="bg-gray-50">
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">ID</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">種別</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">ファイル名</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">ステータス</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">メッセージ</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">作成日時</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">完了日時</th>
              <th className="px-4 py-2 text-left text-sm font-medium text-gray-700">操作</th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            {histories.length === 0 ? (
              <tr>
                <td colSpan={8} className="px-4 py-8 text-center text-gray-500">
                  {loading ? '読み込み中...' : 'アップロード履歴がありません'}
                </td>
              </tr>
            ) : (
              histories.map((history) => (
                <tr key={history.id} className="hover:bg-gray-50">
                  <td className="px-4 py-2 text-sm text-gray-900">{history.id}</td>
                  <td className="px-4 py-2 text-sm text-gray-900">{history.upload_type}</td>
                  <td className="px-4 py-2 text-sm text-gray-900">{history.file_name}</td>
                  <td className="px-4 py-2">
                    {/* ステータスバッジ */}
                    <span className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(history.status)}`}>
                      {getStatusText(history.status)}
                    </span>
                  </td>
                  <td className="px-4 py-2 text-sm text-gray-900">
                    {history.message || '-'}
                  </td>
                  <td className="px-4 py-2 text-sm text-gray-900">
                    {/* UTC→JST変換をClientOnlyで包む */}
                    <ClientOnly fallback="読み込み中...">
                      {formatUTCToJST(history.created_at)}
                    </ClientOnly>
                  </td>
                  <td className="px-4 py-2 text-sm text-gray-900">
                    <ClientOnly fallback="-">
                      {history.completed_at ? formatUTCToJST(history.completed_at) : '-'}
                    </ClientOnly>
                  </td>
                  <td className="px-4 py-2">
                    {/* 処理中の場合のみ操作ボタンを表示 */}
                    {history.status === 0 && (
                      <div className="flex space-x-1">
                        <button
                          onClick={() => updateStatus(history.id, 2, '処理完了')}
                          className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
                        >
                          完了
                        </button>
                        <button
                          onClick={() => updateStatus(history.id, 3, 'エラーが発生しました')}
                          className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
                        >
                          エラー
                        </button>
                      </div>
                    )}
                  </td>
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>

      {/* 自動更新インジケータ */}
      {isAutoRefresh && (
        <div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
          <div className="flex items-center">
            <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
            <span className="text-sm text-blue-700">
              2秒間隔で履歴を自動更新中...
            </span>
          </div>
        </div>
      )}
    </div>
  );
}

この実装により、ユーザーは長時間処理の進行状況をリアルタイムで把握でき、処理完了時に自動的に最新の状況が反映されます。

実装で学んだポイント

1. ポーリング vs WebSocket:どちらを選ぶ?

リアルタイム更新の手法として、ポーリングとWebSocketがあります。
今回は遅延など考慮の必要がないため、比較的シンプルな、ポーリングでの実装を行いました。

ポーリングのメリット

  • 実装がシンプル
  • サーバー負荷が予測しやすい
  • 接続が切れても自動的に復旧

WebSocketとの使い分け

  • リアルタイム性がそれほど重要でない処理監視 → ポーリング
  • チャットやゲームなど即座の応答が必要 → WebSocket

実装してみて気づいたこと

シンプルだけど奥が深い

今回の機能は一見シンプルですが、実装してみると考慮すべき点が意外と多いことがわかりました。

  • パフォーマンス: 無駄なリクエストを減らすポーリング制御
  • ユーザビリティ: 処理状況の適切なフィードバック
  • 堅牢性: エラーハンドリングとネットワーク障害への対応
  • 保守性: コードの可読性と拡張性
  • 国際化: タイムゾーンや多言語への対応

業務では更に複雑

実際の業務システムでは、更に以下のような要素が加わります。

  • 大量データの並列処理
  • プログレスバーでの進捗表示
  • 失敗時のリトライ機能
  • 管理者向けの詳細ログ
  • 複数ユーザーでの同時利用

基本的な実装パターンを理解しておくことで、こうした複雑な拡張にも対応できるようになります。

おわりに

今回は、リアルタイム処理状況監視の実装を通して、以下のことを学びました

  1. ポーリング実装の適切な設計とパフォーマンス配慮
  2. 時刻管理のベストプラクティスとタイムゾーン対応
  3. アイテムステータスの適切な管理

特に印象的だったのは、「単純な機能でも、ユーザー体験を考慮すると意外と設計が重要」ということです。

学生時代の私は「動けばOK」と考えがちでしたが、実際の開発では「どう動くか」が非常に重要だと実感しています。

このような基本的な実装パターンを一つずつ積み重ねることで、より複雑なシステムにも対応できる技術力・設計力が身につくと感じました。

皆さんの学習の参考になれば幸いです!