0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

簡易工程進捗管理システムの改良⑧~フェーズ5 Next.js フロントエンド作成~

0
Last updated at Posted at 2026-10-04

前の記事

次の記事

フェーズ5-6:工程一覧画面を作成して、GET /api/Processes をNext.jsから取得する

工程一覧取得処理を作る

src/lib/process-api.ts

import { cookies } from "next/headers";

/**
 * ASP.NET Core側のProcessDtoに対応する型。
 *
 * C#:
 *   ProcessDto
 *
 * JSONではcamelCaseになるため、
 * TypeScript側もcamelCaseで定義する。
 */
export type ProcessDto = {
  id: number;
  processName: string;
  displayOrder: number;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
};

/**
 * 工程一覧を取得する。
 *
 * ブラウザから直接APIを呼ぶのではなく、
 * Next.jsサーバーからASP.NET Core APIを呼び出す。
 */
export async function getProcesses(): Promise<ProcessDto[]> {
  // --------------------------------------------------
  // CookieからJWTを取得
  // --------------------------------------------------

  const cookieStore = await cookies();

  const accessToken =
    cookieStore.get("access_token")?.value;

  // JWTが存在しない場合
  if (!accessToken) {
    throw new Error("アクセストークンがありません。");
  }

  // --------------------------------------------------
  // APIのURLを取得
  // --------------------------------------------------

  const apiBaseUrl = process.env.API_BASE_URL;

  if (!apiBaseUrl) {
    throw new Error(
      "API_BASE_URLが設定されていません。"
    );
  }

  // --------------------------------------------------
  // ASP.NET Core APIを呼び出す
  // --------------------------------------------------

  const response = await fetch(
    `${apiBaseUrl}/api/Processes`,
    {
      method: "GET",

      headers: {
        // JWTをAuthorizationヘッダーに設定する
        Authorization: `Bearer ${accessToken}`,
      },

      // 工程情報は最新状態を取得したいため、
      // Next.js側ではキャッシュしない。
      cache: "no-store",
    }
  );

  // --------------------------------------------------
  // HTTPエラー
  // --------------------------------------------------

  if (!response.ok) {
    throw new Error(
      `工程一覧の取得に失敗しました。status=${response.status}`
    );
  }

  // --------------------------------------------------
  // JSON → ProcessDto[]
  // --------------------------------------------------

  const data =
    (await response.json()) as ProcessDto[];

  return data;
}

工程一覧ページを作る

src/app/processes/page.tsx

import Link from "next/link";
import { getProcesses } from "@/lib/process-api";

/**
 * 工程一覧画面。
 *
 * Server Componentなので、
 * コンポーネント内で直接awaitを使用できる。
 */
export default async function ProcessesPage() {
  // ASP.NET Core APIから工程一覧を取得する
  const processes = await getProcesses();

  return (
    <main>
      <h1>工程一覧</h1>

      {/* 工程が1件も登録されていない場合 */}
      {processes.length === 0 ? (
        <p>工程が登録されていません。</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>工程名</th>
              <th>表示順</th>
              <th>有効</th>
            </tr>
          </thead>

          <tbody>
            {processes.map((process) => (
              <tr key={process.id}>
                <td>{process.id}</td>

                <td>
                  {process.processName}
                </td>

                <td>
                  {process.displayOrder}
                </td>

                <td>
                  {process.isActive
                    ? "有効"
                    : "無効"}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}

      <p>
        <Link href="/">
          トップへ戻る
        </Link>
      </p>
    </main>
  );
}

トップ画面から工程一覧へ移動できるようにする

src/app/page.tsxを変更。

proxy.tsも変更

src/proxy.ts
/processesに認証ガードをつける。

export const config = {
  matcher: ["/", "/login", "/processes/:path*"],
};

動作確認

dockerでAPI起動。

Next.js起動。

npm.cmd run dev

ブラウザで、

http://localhost:3000/login

からログインする。

フェーズ5-7:工程を選択して、その工程の作業一覧を表示する

Task APIの取得処理を作成

src/lib/task-api.ts

import { cookies } from "next/headers";

/**
 * ASP.NET Core側のTaskDtoに対応する型。
 */
export type TaskDto = {
  id: number;
  taskName: string;
  status: string;
  plannedDate: string | null;
  createdAt: string;
  updatedAt: string;

  // 所属している工程
  processId: number;
  processName: string | null;
};

/**
 * GET /api/Tasks のページングレスポンス。
 *
 * ASP.NET Core側では作業一覧を
 * itemsの中に格納して返している。
 */
type TaskPagedResponse = {
  items: TaskDto[];

  page: number;
  pageSize: number;

  totalCount: number;
  totalPages: number;
};

/**
 * 指定した工程に所属する作業一覧を取得する。
 *
 * @param processId 工程ID
 */
export async function getTasksByProcessId(
  processId: number
): Promise<TaskDto[]> {
  // -----------------------------------------------
  // CookieからJWTを取得
  // -----------------------------------------------

  const cookieStore = await cookies();

  const accessToken =
    cookieStore.get("access_token")?.value;

  if (!accessToken) {
    throw new Error(
      "アクセストークンがありません。"
    );
  }

  // -----------------------------------------------
  // API接続先
  // -----------------------------------------------

  const apiBaseUrl = process.env.API_BASE_URL;

  if (!apiBaseUrl) {
    throw new Error(
      "API_BASE_URLが設定されていません。"
    );
  }

  // -----------------------------------------------
  // ASP.NET Core APIを呼び出す
  // -----------------------------------------------
  //
  // 例:
  // GET /api/Tasks?processId=2
  //
  const response = await fetch(
    `${apiBaseUrl}/api/Tasks?processId=${processId}`,
    {
      method: "GET",

      headers: {
        // JWTをASP.NET Coreへ送信する
        Authorization: `Bearer ${accessToken}`,
      },

      // 常に最新の作業一覧を取得する
      cache: "no-store",
    }
  );

  // -----------------------------------------------
  // HTTPエラー
  // -----------------------------------------------

  if (!response.ok) {
    throw new Error(
      `作業一覧の取得に失敗しました。status=${response.status}`
    );
  }

  // APIからページングされたレスポンスを取得する
    const data =
    (await response.json()) as TaskPagedResponse;

    // 実際の作業一覧はitemsに入っている
    return data.items;
}

工程一覧から作業一覧へ遷移

src/app/processes/page.tsxを変更する。

import Link from "next/link";
import { getProcesses } from "@/lib/process-api";

/**
 * 工程一覧画面
 */
export default async function ProcessesPage() {
  // ASP.NET Core APIから工程一覧を取得
  const processes = await getProcesses();

  return (
    <main>
      <h1>工程一覧</h1>

      {processes.length === 0 ? (
        <p>工程が登録されていません。</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>工程名</th>
              <th>表示順</th>
              <th>有効</th>
            </tr>
          </thead>

          <tbody>
            {processes.map((process) => (
              <tr key={process.id}>
                <td>
                  {process.id}
                </td>

                <td>
                  {/*
                    工程名をクリックすると、
                    その工程の作業一覧へ移動する。

                    例:
                    /processes/2/tasks
                  */}
                  <Link
                    href={`/processes/${process.id}/tasks`}
                  >
                    {process.processName}
                  </Link>
                </td>

                <td>
                  {process.displayOrder}
                </td>

                <td>
                  {process.isActive
                    ? "有効"
                    : "無効"}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}

      <p>
        <Link href="/">
          トップへ戻る
        </Link>
      </p>
    </main>
  );
}

動的ルートを作成

Next.jsのDynamic Routeを使う。
src/app/processes/[id]/tasks/page.tsx

import Link from "next/link";
import { notFound } from "next/navigation";

import {
  getTasksByProcessId,
} from "@/lib/task-api";

/**
 * 工程ごとの作業一覧画面
 */
export default async function ProcessTasksPage(
  props: {
    params: Promise<{
      id: string;
    }>;
  }
) {
  // -----------------------------------------------
  // URLから工程IDを取得
  // -----------------------------------------------
  //
  // /processes/2/tasks
  //
  // の場合、
  //
  // params.id = "2"
  //
  const params = await props.params;

  const processId =
    Number(params.id);

  // -----------------------------------------------
  // 不正なIDをチェック
  // -----------------------------------------------

  if (
    !Number.isInteger(processId) ||
    processId <= 0
  ) {
    notFound();
  }

  // -----------------------------------------------
  // 指定された工程の作業一覧を取得
  // -----------------------------------------------

  const tasks =
    await getTasksByProcessId(processId);

  return (
    <main>
      <h1>作業一覧</h1>

      <p>
        工程ID:{processId}
      </p>

      {/* 作業が存在しない場合 */}
      {tasks.length === 0 ? (
        <p>
          この工程には作業が登録されていません。
        </p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>作業名</th>
              <th>ステータス</th>
              <th>予定日</th>
            </tr>
          </thead>

          <tbody>
            {tasks.map((task) => (
              <tr key={task.id}>
                <td>
                  {task.id}
                </td>

                <td>
                  {task.taskName}
                </td>

                <td>
                  {task.status}
                </td>

                <td>
                  {/*
                    plannedDateがnullの場合は
                    "-" を表示する。
                  */}
                  {task.plannedDate ?? "-"}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}

      <p>
        <Link href="/processes">
          工程一覧へ戻る
        </Link>
      </p>
    </main>
  );
}

動作確認

工程一覧の各工程をクリックすると、作業一覧に遷移することを確認。

フェーズ5-8:作業ステータスを更新する

ステータスの型を定義

src/lib/task-api.tsのTaskDtoを変更する。

status: string;

を

status: WorkTaskStatus;

に変更する。

TaskDtoの前に、

/**
 * ASP.NET Core側のWorkTaskStatusに対応する。
 */
export type WorkTaskStatus =
  | "NotStarted"
  | "InProgress"
  | "Completed";

を追加する。

更新用Server Actionを作成

src/app/processes/[id]/tasks/actions.ts

"use server";

import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";

import type {
  WorkTaskStatus,
} from "@/lib/task-api";

/**
 * 作業ステータスを更新するServer Action。
 */
export async function updateTaskStatus(
  formData: FormData
) {
  // -----------------------------------------------
  // formから値を取得
  // -----------------------------------------------

  const taskId = Number(
    formData.get("taskId")
  );

  const processId = Number(
    formData.get("processId")
  );

  const taskName = String(
    formData.get("taskName") ?? ""
  );

  const status = String(
    formData.get("status") ?? ""
  ) as WorkTaskStatus;

  const plannedDateValue = String(
    formData.get("plannedDate") ?? ""
  );

  // 空文字の場合はnullにする
  const plannedDate =
    plannedDateValue === ""
      ? null
      : plannedDateValue;

  // -----------------------------------------------
  // JWTを取得
  // -----------------------------------------------

  const cookieStore = await cookies();

  const accessToken =
    cookieStore.get("access_token")?.value;

  if (!accessToken) {
    throw new Error(
      "アクセストークンがありません。"
    );
  }

  // -----------------------------------------------
  // API接続先
  // -----------------------------------------------

  const apiBaseUrl =
    process.env.API_BASE_URL;

  if (!apiBaseUrl) {
    throw new Error(
      "API_BASE_URLが設定されていません。"
    );
  }

  // -----------------------------------------------
  // ASP.NET Core APIで作業を更新
  // -----------------------------------------------

  const response = await fetch(
    `${apiBaseUrl}/api/Tasks/${taskId}`,
    {
      method: "PUT",

      headers: {
        "Content-Type": "application/json",

        // JWT認証
        Authorization:
          `Bearer ${accessToken}`,
      },

      body: JSON.stringify({
        // 現在の作業名は変更しない
        taskName,

        // 現在所属している工程もそのまま送る
        processId,

        // 今回変更する項目
        status,

        // 予定日も現在値を維持する
        plannedDate,
      }),
    }
  );

  // -----------------------------------------------
  // 更新失敗
  // -----------------------------------------------

  if (!response.ok) {
    const errorText =
      await response.text();

    console.error(
      "作業更新APIエラー",
      response.status,
      errorText
    );

    throw new Error(
      `作業の更新に失敗しました。status=${response.status}`
    );
  }

  // -----------------------------------------------
  // 作業一覧を再取得させる
  // -----------------------------------------------

  revalidatePath(
    `/processes/${processId}/tasks`
  );
}

作業一覧にステータス変更ボタンを追加src/app/processes/[id]/tasks/page.tsxを変更する。

importを追加する。

import {  
    updateTaskStatus,
 } from "./actions";

ステータスを日本語表示する関数を追加する。

/**
 * APIのステータスを日本語表示に変換する。
 */
function getStatusLabel(
  status: string
) {
  switch (status) {
    case "NotStarted":
      return "未着手";

    case "InProgress":
      return "作業中";

    case "Completed":
      return "完了";

    default:
      return status;
  }
}

テーブルを変更する。

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>作業名</th>
      <th>ステータス</th>
      <th>予定日</th>
      <th>ステータス変更</th>
    </tr>
  </thead>

  <tbody>
    {tasks.map((task) => (
      <tr key={task.id}>
        <td>
          {task.id}
        </td>

        <td>
          {task.taskName}
        </td>

        <td>
          {getStatusLabel(task.status)}
        </td>

        <td>
          {task.plannedDate ?? "-"}
        </td>

        <td>
          {/*
            1作業につき1つのformを用意する。

            押したボタンのvalueが
            statusとしてServer Actionへ渡される。
          */}
          <form action={updateTaskStatus}>

            {/* 更新に必要な現在値 */}
            <input
              type="hidden"
              name="taskId"
              value={task.id}
            />

            <input
              type="hidden"
              name="processId"
              value={task.processId}
            />

            <input
              type="hidden"
              name="taskName"
              value={task.taskName}
            />

            <input
              type="hidden"
              name="plannedDate"
              value={task.plannedDate ?? ""}
            />

            {/* 未着手 */}
            <button
              type="submit"
              name="status"
              value="NotStarted"
              disabled={
                task.status === "NotStarted"
              }
            >
              未着手
            </button>

            {/* 作業中 */}
            <button
              type="submit"
              name="status"
              value="InProgress"
              disabled={
                task.status === "InProgress"
              }
            >
              作業中
            </button>

            {/* 完了 */}
            <button
              type="submit"
              name="status"
              value="Completed"
              disabled={
                task.status === "Completed"
              }
            >
              完了
            </button>

          </form>
        </td>
      </tr>
    ))}
  </tbody>
</table>

動作確認

ステータス変更のステータスをクリックすると、ステータスが更新されることを確認。

フェーズ5-9:工程進捗画面のUI改善

CSS Modulesを作成

src/app/processes/[id]/tasks/tasks.module.css

page.tsxを変更

import Link from "next/link";
import { notFound } from "next/navigation";

import {
  getTasksByProcessId,
  type WorkTaskStatus,
} from "@/lib/task-api";

import {
  getProcesses,
} from "@/lib/process-api";

import {
  updateTaskStatus,
} from "./actions";

import styles from "./tasks.module.css";

/**
 * ステータスを画面表示用の日本語へ変換する。
 */
function getStatusLabel(
  status: WorkTaskStatus
) {
  switch (status) {
    case "NotStarted":
      return "未着手";

    case "InProgress":
      return "作業中";

    case "Completed":
      return "完了";
  }
}

/**
 * ステータスに応じたCSSクラスを返す。
 */
function getStatusClass(
  status: WorkTaskStatus
) {
  switch (status) {
    case "NotStarted":
      return styles.notStarted;

    case "InProgress":
      return styles.inProgress;

    case "Completed":
      return styles.completed;
  }
}

/**
 * YYYY-MM-DD
 *
 * を
 *
 * YYYY/MM/DD
 *
 * に変換する。
 */
function formatDate(
  date: string | null
) {
  if (!date) {
    return "-";
  }

  return date.replaceAll("-", "/");
}

/**
 * 工程ごとの作業一覧画面。
 */
export default async function ProcessTasksPage(
  props: {
    params: Promise<{
      id: string;
    }>;
  }
) {
  // --------------------------------------------------
  // URLから工程IDを取得
  // --------------------------------------------------

  const params = await props.params;

  const processId =
    Number(params.id);

  // 工程IDとして不正な値なら404
  if (
    !Number.isInteger(processId) ||
    processId <= 0
  ) {
    notFound();
  }

  // --------------------------------------------------
  // 工程と作業を取得
  // --------------------------------------------------
  //
  // 2つのAPIは互いに依存していないため、
  // Promise.allで並行して取得する。
  //
  const [
    processes,
    tasks,
  ] = await Promise.all([
    getProcesses(),
    getTasksByProcessId(processId),
  ]);

  // URLで指定された工程を探す
  const process =
    processes.find(
      (item) => item.id === processId
    );

  // 存在しない工程IDなら404
  if (!process) {
    notFound();
  }

  return (
    <main className={styles.container}>

      {/* =====================================
          画面タイトル
          ===================================== */}
      <header className={styles.header}>
        <h1 className={styles.title}>
          {process.processName}
        </h1>

        <p className={styles.description}>
          工程ごとの作業進捗を確認・更新できます。
        </p>
      </header>

      {/* =====================================
          作業一覧
          ===================================== */}

      {tasks.length === 0 ? (
        <p>
          この工程には作業が登録されていません。
        </p>
      ) : (
        <div className={styles.tableWrapper}>
          <table className={styles.table}>

            <thead>
              <tr>
                <th>ID</th>
                <th>作業名</th>
                <th>ステータス</th>
                <th>予定日</th>
                <th>ステータス変更</th>
              </tr>
            </thead>

            <tbody>
              {tasks.map((task) => (
                <tr key={task.id}>

                  {/* ID */}
                  <td>
                    {task.id}
                  </td>

                  {/* 作業名 */}
                  <td>
                    {task.taskName}
                  </td>

                  {/* 現在のステータス */}
                  <td>
                    <span
                      className={
                        `${styles.statusBadge} ${getStatusClass(task.status)}`
                      }
                    >
                      {getStatusLabel(task.status)}
                    </span>
                  </td>

                  {/* 予定日 */}
                  <td>
                    {formatDate(
                      task.plannedDate
                    )}
                  </td>

                  {/* =================================
                      ステータス更新
                      ================================= */}
                  <td>
                    <form
                      action={updateTaskStatus}
                      className={styles.buttonGroup}
                    >
                      {/* 更新APIへ渡す現在値 */}

                      <input
                        type="hidden"
                        name="taskId"
                        value={task.id}
                      />

                      <input
                        type="hidden"
                        name="processId"
                        value={task.processId}
                      />

                      <input
                        type="hidden"
                        name="taskName"
                        value={task.taskName}
                      />

                      <input
                        type="hidden"
                        name="plannedDate"
                        value={
                          task.plannedDate ?? ""
                        }
                      />

                      {/* 未着手 */}
                      <button
                        className={
                          styles.statusButton
                        }
                        type="submit"
                        name="status"
                        value="NotStarted"
                        disabled={
                          task.status ===
                          "NotStarted"
                        }
                      >
                        未着手
                      </button>

                      {/* 作業中 */}
                      <button
                        className={
                          styles.statusButton
                        }
                        type="submit"
                        name="status"
                        value="InProgress"
                        disabled={
                          task.status ===
                          "InProgress"
                        }
                      >
                        作業中
                      </button>

                      {/* 完了 */}
                      <button
                        className={
                          styles.statusButton
                        }
                        type="submit"
                        name="status"
                        value="Completed"
                        disabled={
                          task.status ===
                          "Completed"
                        }
                      >
                        完了
                      </button>

                    </form>
                  </td>

                </tr>
              ))}
            </tbody>

          </table>
        </div>
      )}

      {/* =====================================
          戻る
          ===================================== */}
      <div className={styles.footer}>
        <Link href="/processes">
          ← 工程一覧へ戻る
        </Link>
      </div>

    </main>
  );
}

フェーズ5-10:工程一覧を進捗ダッシュボード化

工程ごとに進捗が分かる画面へ変更する。

┌────────────────────────────┐
│ 組立工程                    │
│ 全体 10件                   │
│ 未着手 3  作業中 2  完了 5 │
│ ██████████ 50%            │
└────────────────────────────┘

進捗集計処理を追加

src/lib/task-api.tsに追加する。

type TaskPagedResponse = {

の下に、

/**
 * 工程ごとの作業進捗。
 */
export type TaskProgress = {
  total: number;
  notStarted: number;
  inProgress: number;
  completed: number;
};

を追加する。

ファイルの最後に次を追加する。

/**
 * 指定した工程の全作業を取得して、
 * ステータス別の件数を集計する。
 *
 * GET /api/Tasks はページングされているため、
 * 必要に応じて2ページ目以降も取得する。
 */
export async function getTaskProgressByProcessId(
  processId: number
): Promise<TaskProgress> {
  // -----------------------------------------------
  // JWT取得
  // -----------------------------------------------

  const cookieStore = await cookies();

  const accessToken =
    cookieStore.get("access_token")?.value;

  if (!accessToken) {
    throw new Error(
      "アクセストークンがありません。"
    );
  }

  // -----------------------------------------------
  // API URL取得
  // -----------------------------------------------

  const apiBaseUrl =
    process.env.API_BASE_URL;

  if (!apiBaseUrl) {
    throw new Error(
      "API_BASE_URLが設定されていません。"
    );
  }

  // 1回に取得する件数
  const pageSize = 100;

  /**
   * 指定ページを取得する内部関数。
   */
  async function fetchPage(
    page: number
  ): Promise<TaskPagedResponse> {
    const response = await fetch(
      `${apiBaseUrl}/api/Tasks?processId=${processId}&page=${page}&pageSize=${pageSize}`,
      {
        method: "GET",

        headers: {
          Authorization:
            `Bearer ${accessToken}`,
        },

        cache: "no-store",
      }
    );

    if (!response.ok) {
      throw new Error(
        `作業進捗の取得に失敗しました。status=${response.status}`
      );
    }

    return (
      await response.json()
    ) as TaskPagedResponse;
  }

  // -----------------------------------------------
  // まず1ページ目を取得
  // -----------------------------------------------

  const firstPage =
    await fetchPage(1);

  // 全作業をここへ格納する
  const allTasks = [
    ...firstPage.items,
  ];

  // -----------------------------------------------
  // 2ページ目以降が存在する場合
  // -----------------------------------------------

  if (firstPage.totalPages > 1) {
    const requests = [];

    for (
      let page = 2;
      page <= firstPage.totalPages;
      page++
    ) {
      requests.push(
        fetchPage(page)
      );
    }

    // 複数ページを並行取得する
    const remainingPages =
      await Promise.all(requests);

    for (
      const page of remainingPages
    ) {
      allTasks.push(
        ...page.items
      );
    }
  }

  // -----------------------------------------------
  // ステータス別に集計
  // -----------------------------------------------

  const notStarted =
    allTasks.filter(
      (task) =>
        task.status === "NotStarted"
    ).length;

  const inProgress =
    allTasks.filter(
      (task) =>
        task.status === "InProgress"
    ).length;

  const completed =
    allTasks.filter(
      (task) =>
        task.status === "Completed"
    ).length;

  return {
    total: allTasks.length,
    notStarted,
    inProgress,
    completed,
  };
}

工程一覧用CSSを作る

src/app/processes/processes.module.css

/* =========================================
   画面全体
   ========================================= */
.container {
  max-width: 1100px;

  margin: 0 auto;
  padding: 32px 24px;
}

/* =========================================
   タイトル
   ========================================= */
.header {
  margin-bottom: 24px;
}

.title {
  margin: 0 0 8px;

  font-size: 28px;
}

.description {
  margin: 0;

  color: #666;
}

/* =========================================
   工程カード一覧
   ========================================= */
.grid {
  display: grid;

  /* 画面幅に応じてカード数を自動調整する */
  grid-template-columns:
    repeat(
      auto-fit,
      minmax(280px, 1fr)
    );

  gap: 16px;
}

/* =========================================
   工程カード
   ========================================= */
.card {
  display: block;

  padding: 20px;

  border: 1px solid #ddd;
  border-radius: 8px;

  background-color: white;

  color: inherit;
  text-decoration: none;

  transition:
    transform 0.15s,
    box-shadow 0.15s;
}

/* カードにマウスを乗せたとき */
.card:hover {
  transform: translateY(-2px);

  box-shadow:
    0 4px 12px
    rgba(0, 0, 0, 0.1);
}

/* =========================================
   工程名
   ========================================= */
.processName {
  margin: 0 0 16px;

  font-size: 20px;
}

/* =========================================
   進捗件数
   ========================================= */
.total {
  margin-bottom: 12px;

  font-weight: bold;
}

.statusList {
  display: flex;

  flex-wrap: wrap;

  gap: 12px;

  margin-bottom: 16px;
}

.statusItem {
  font-size: 14px;
}

/* =========================================
   プログレスバー
   ========================================= */
.progressArea {
  display: flex;

  align-items: center;

  gap: 12px;
}

.progressBar {
  flex: 1;

  height: 10px;

  overflow: hidden;

  border-radius: 5px;

  background-color: #eee;
}

.progressValue {
  height: 100%;

  background-color: #4caf50;
}

.progressText {
  min-width: 44px;

  font-size: 14px;
  font-weight: bold;

  text-align: right;
}

/* =========================================
   下部
   ========================================= */
.footer {
  margin-top: 24px;
}

工程一覧をカード表示に変更

src/app/processes/page.tsxを変更する。

import Link from "next/link";

import {
  getProcesses,
} from "@/lib/process-api";

import {
  getTaskProgressByProcessId,
} from "@/lib/task-api";

import styles from "./processes.module.css";

/**
 * 工程一覧・進捗ダッシュボード。
 */
export default async function ProcessesPage() {
  // -----------------------------------------------
  // 工程一覧を取得
  // -----------------------------------------------

  const processes =
    await getProcesses();

  // -----------------------------------------------
  // 有効な工程だけを表示対象にする
  // -----------------------------------------------
  //
  // isActive = false の工程は、
  // 通常の工程進捗画面には表示しない。
  //
  const activeProcesses =
    processes.filter(
      (process) => process.isActive
    );

  // -----------------------------------------------
  // 各工程の進捗を取得
  // -----------------------------------------------
  //
  // 工程ごとの取得処理は互いに依存しないので、
  // Promise.allで並行実行する。
  //
  const processesWithProgress =
    await Promise.all(
      activeProcesses.map(
        async (process) => {
          const progress =
            await getTaskProgressByProcessId(
              process.id
            );

          return {
            ...process,
            progress,
          };
        }
      )
    );

  return (
    <main className={styles.container}>

      {/* =====================================
          画面上部
          ===================================== */}
      <header className={styles.header}>
        <h1 className={styles.title}>
          工程進捗
        </h1>

        <p className={styles.description}>
          各工程の作業進捗を確認できます。
        </p>
      </header>

      {/* =====================================
          工程一覧
          ===================================== */}
      {processesWithProgress.length === 0 ? (
        <p>
          工程が登録されていません。
        </p>
      ) : (
        <div className={styles.grid}>

          {processesWithProgress.map(
            (process) => {
              const {
                progress,
              } = process;

              // ---------------------------------
              // 完了率を計算
              // ---------------------------------

              const completionRate =
                progress.total === 0
                  ? 0
                  : Math.round(
                      (
                        progress.completed /
                        progress.total
                      ) * 100
                    );

              return (
                <Link
                  key={process.id}
                  href={
                    `/processes/${process.id}/tasks`
                  }
                  className={styles.card}
                >

                  {/* 工程名 */}
                  <h2
                    className={
                      styles.processName
                    }
                  >
                    {process.processName}
                  </h2>

                  {/* 全作業数 */}
                  <div
                    className={styles.total}
                  >
                    全作業:
                    {progress.total}件
                  </div>

                  {/* ステータスごとの件数 */}
                  <div
                    className={
                      styles.statusList
                    }
                  >
                    <span
                      className={
                        styles.statusItem
                      }
                    >
                      未着手:
                      {progress.notStarted}
                    </span>

                    <span
                      className={
                        styles.statusItem
                      }
                    >
                      作業中:
                      {progress.inProgress}
                    </span>

                    <span
                      className={
                        styles.statusItem
                      }
                    >
                      完了:
                      {progress.completed}
                    </span>
                  </div>

                  {/* =================================
                      完了率
                      ================================= */}
                  <div
                    className={
                      styles.progressArea
                    }
                  >
                    <div
                      className={
                        styles.progressBar
                      }
                    >
                      <div
                        className={
                          styles.progressValue
                        }
                        style={{
                          width:
                            `${completionRate}%`,
                        }}
                      />
                    </div>

                    <span
                      className={
                        styles.progressText
                      }
                    >
                      {completionRate}%
                    </span>
                  </div>

                </Link>
              );
            }
          )}

        </div>
      )}

      <div className={styles.footer}>
        <Link href="/">
          ← トップへ戻る
        </Link>
      </div>

    </main>
  );
}

フェーズ5-11:トップ画面をダッシュボード化

トップ画面用CSSを作成

src/app/dashboard.module.css

/* =========================================
   画面全体
   ========================================= */
.container {
  max-width: 1100px;

  margin: 0 auto;
  padding: 32px 24px;
}

/* =========================================
   画面上部
   ========================================= */
.header {
  margin-bottom: 32px;
}

.title {
  margin: 0 0 8px;

  font-size: 30px;
}

.description {
  margin: 0;

  color: #666;
}

/* =========================================
   サマリーカード一覧
   ========================================= */
.summaryGrid {
  display: grid;

  /* 画面幅によってカード数を自動調整する */
  grid-template-columns:
    repeat(
      auto-fit,
      minmax(200px, 1fr)
    );

  gap: 16px;

  margin-bottom: 32px;
}

/* =========================================
   サマリーカード
   ========================================= */
.summaryCard {
  padding: 24px;

  border: 1px solid #ddd;
  border-radius: 8px;

  background-color: white;
}

/* カードの項目名 */
.summaryLabel {
  margin-bottom: 8px;

  color: #666;

  font-size: 14px;
}

/* カードの数値 */
.summaryValue {
  font-size: 32px;
  font-weight: bold;
}

/* =========================================
   メニュー
   ========================================= */
.menu {
  margin-bottom: 32px;
}

/* 工程進捗画面へのリンク */
.primaryLink {
  display: inline-block;

  padding: 12px 20px;

  border-radius: 6px;

  background-color: #333;

  color: white;
  text-decoration: none;
}

/* マウスを乗せたとき */
.primaryLink:hover {
  opacity: 0.85;
}

/* =========================================
   ログアウト
   ========================================= */
.logoutButton {
  padding: 10px 18px;

  border: 1px solid #bbb;
  border-radius: 6px;

  background-color: white;

  cursor: pointer;
}

.logoutButton:hover {
  background-color: #f5f5f5;
}

src/app/page.tsxを変更

import Link from "next/link";

import {
  getProcesses,
} from "@/lib/process-api";

import {
  getTaskProgressByProcessId,
} from "@/lib/task-api";

import {
  logoutAction,
} from "./actions/logout";

import styles from "./dashboard.module.css";

/**
 * トップダッシュボード。
 */
export default async function Home() {
  // --------------------------------------------------
  // 工程一覧を取得
  // --------------------------------------------------

  const processes =
    await getProcesses();

  // --------------------------------------------------
  // 全工程の進捗を取得
  // --------------------------------------------------
  //
  // 各工程は互いに依存していないため、
  // Promise.allで並行して取得する。
  //
  const progresses =
    await Promise.all(
      processes.map(
        (process) =>
          getTaskProgressByProcessId(
            process.id
          )
      )
    );

  // --------------------------------------------------
  // システム全体の件数を集計
  // --------------------------------------------------

  const totalTasks =
    progresses.reduce(
      (total, progress) =>
        total + progress.total,
      0
    );

  const totalNotStarted =
    progresses.reduce(
      (total, progress) =>
        total + progress.notStarted,
      0
    );

  const totalInProgress =
    progresses.reduce(
      (total, progress) =>
        total + progress.inProgress,
      0
    );

  const totalCompleted =
    progresses.reduce(
      (total, progress) =>
        total + progress.completed,
      0
    );

  // --------------------------------------------------
  // 全体の完了率
  // --------------------------------------------------

  const completionRate =
    totalTasks === 0
      ? 0
      : Math.round(
          (
            totalCompleted /
            totalTasks
          ) * 100
        );

  return (
    <main className={styles.container}>

      {/* =====================================
          タイトル
          ===================================== */}
      <header className={styles.header}>
        <h1 className={styles.title}>
          工程進捗管理システム
        </h1>

        <p className={styles.description}>
          工程全体の作業状況を確認できます。
        </p>
      </header>

      {/* =====================================
          全体サマリー
          ===================================== */}
      <section
        className={styles.summaryGrid}
      >

        {/* 工程数 */}
        <div
          className={styles.summaryCard}
        >
          <div
            className={styles.summaryLabel}
          >
            工程数
          </div>

          <div
            className={styles.summaryValue}
          >
            {processes.length}
          </div>
        </div>

        {/* 全作業数 */}
        <div
          className={styles.summaryCard}
        >
          <div
            className={styles.summaryLabel}
          >
            全作業数
          </div>

          <div
            className={styles.summaryValue}
          >
            {totalTasks}
          </div>
        </div>

        {/* 未着手 */}
        <div
          className={styles.summaryCard}
        >
          <div
            className={styles.summaryLabel}
          >
            未着手
          </div>

          <div
            className={styles.summaryValue}
          >
            {totalNotStarted}
          </div>
        </div>

        {/* 作業中 */}
        <div
          className={styles.summaryCard}
        >
          <div
            className={styles.summaryLabel}
          >
            作業中
          </div>

          <div
            className={styles.summaryValue}
          >
            {totalInProgress}
          </div>
        </div>

        {/* 完了 */}
        <div
          className={styles.summaryCard}
        >
          <div
            className={styles.summaryLabel}
          >
            完了
          </div>

          <div
            className={styles.summaryValue}
          >
            {totalCompleted}
          </div>
        </div>

        {/* 完了率 */}
        <div
          className={styles.summaryCard}
        >
          <div
            className={styles.summaryLabel}
          >
            完了率
          </div>

          <div
            className={styles.summaryValue}
          >
            {completionRate}%
          </div>
        </div>

      </section>

      {/* =====================================
          メニュー
          ===================================== */}
      <section className={styles.menu}>
        <Link
          href="/processes"
          className={styles.primaryLink}
        >
          工程進捗を見る
        </Link>
      </section>

      {/* =====================================
          ログアウト
          ===================================== */}
      <form action={logoutAction}>
        <button
          type="submit"
          className={styles.logoutButton}
        >
          ログアウト
        </button>
      </form>

    </main>
  );
}

動作確認

トップページで工程全体の作業状況が見られることを確認。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?