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
Posted at

前の記事

フェーズ5-17:工程の登録・編集画面

process-api.tsを拡張

src/lib/process-api.tsに、工程1件取得・登録・更新を追加する。

import { cookies } from "next/headers";

/**
 * 工程情報
 *
 * APIのProcessDtoに対応する型。
 */
export type ProcessDto = {
  id: number;
  processName: string;
  displayOrder: number;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
};

/**
 * 工程登録時にAPIへ送信するデータ
 */
export type CreateProcessRequest = {
  processName: string;
  displayOrder: number;
  isActive: boolean;
};

/**
 * 工程更新時にAPIへ送信するデータ
 */
export type UpdateProcessRequest = {
  processName: string;
  displayOrder: number;
  isActive: boolean;
};

/**
 * CookieからJWTを取得する。
 *
 * ログイン時にaccess_tokenという名前で
 * HttpOnly Cookieへ保存している。
 */
async function getAccessToken(): Promise<string> {
  const cookieStore = await cookies();

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

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

  return accessToken;
}

/**
 * 工程一覧を取得する。
 */
export async function getProcesses(): Promise<ProcessDto[]> {
  const accessToken = await getAccessToken();

  const apiBaseUrl = process.env.API_BASE_URL;

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

  const response = await fetch(`${apiBaseUrl}/api/Processes`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },

    // 毎回APIから最新データを取得する
    cache: "no-store",
  });

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

  return response.json();
}

/**
 * 指定したIDの工程を1件取得する。
 *
 * 編集画面の初期表示で使用する。
 */
export async function getProcess(id: number): Promise<ProcessDto> {
  const accessToken = await getAccessToken();

  const apiBaseUrl = process.env.API_BASE_URL;

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

  const response = await fetch(`${apiBaseUrl}/api/Processes/${id}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },

    cache: "no-store",
  });

  if (!response.ok) {
    throw new Error("工程情報の取得に失敗しました。");
  }

  return response.json();
}

/**
 * 工程を新規登録する。
 */
export async function createProcess(
  request: CreateProcessRequest
): Promise<ProcessDto> {
  const accessToken = await getAccessToken();

  const apiBaseUrl = process.env.API_BASE_URL;

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

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

    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },

    body: JSON.stringify(request),
  });

  if (!response.ok) {
    throw new Error("工程の登録に失敗しました。");
  }

  return response.json();
}

/**
 * 工程情報を更新する。
 */
export async function updateProcess(
  id: number,
  request: UpdateProcessRequest
): Promise<void> {
  const accessToken = await getAccessToken();

  const apiBaseUrl = process.env.API_BASE_URL;

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

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

    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },

    body: JSON.stringify(request),
  });

  if (!response.ok) {
    throw new Error("工程の更新に失敗しました。");
  }
}

工程の新規登録画面

actions.tsを作成

src/app/(authenticated)/processes/actions.ts

"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { createProcess } from "@/lib/process-api";

/**
 * 工程登録フォームから呼び出されるServer Action。
 *
 * ブラウザから直接ASP.NET Core APIを呼ぶのではなく、
 * Next.jsのサーバー側からAPIを呼び出す。
 */
export async function createProcessAction(formData: FormData) {
  // ---------------------------------------------------------
  // フォームから入力値を取得
  // ---------------------------------------------------------
  const processName = formData.get("processName")?.toString().trim() ?? "";

  const displayOrderText =
    formData.get("displayOrder")?.toString() ?? "0";

  const isActive = formData.get("isActive") === "on";

  // ---------------------------------------------------------
  // 表示順をnumber型へ変換
  // ---------------------------------------------------------
  const displayOrder = Number(displayOrderText);

  // ---------------------------------------------------------
  // 最低限の入力チェック
  // ---------------------------------------------------------
  if (!processName) {
    throw new Error("工程名を入力してください。");
  }

  if (Number.isNaN(displayOrder)) {
    throw new Error("表示順には数値を入力してください。");
  }

  // ---------------------------------------------------------
  // ASP.NET Core APIへ登録要求を送信
  // ---------------------------------------------------------
  await createProcess({
    processName,
    displayOrder,
    isActive,
  });

  // ---------------------------------------------------------
  // 工程一覧画面のキャッシュを無効化
  //
  // 登録後に一覧画面へ戻ったとき、
  // 新しいデータが表示されるようにする。
  // ---------------------------------------------------------
  revalidatePath("/processes");

  // ---------------------------------------------------------
  // 登録完了後、工程一覧へ戻る
  // ---------------------------------------------------------
  redirect("/processes");
}

new/page.tsxを作成

src/app/(authenticated)/processes/new/page.tsx

import Link from "next/link";
import { createProcessAction } from "../actions";
import styles from "../processes.module.css";

/**
 * 工程新規登録画面
 */
export default function NewProcessPage() {
  return (
    <main className={styles.container}>
      {/* =========================================
          画面タイトル
          ========================================= */}
      <div className={styles.header}>
        <h1 className={styles.title}>工程登録</h1>

        <p className={styles.description}>
          新しい工程を登録します。
        </p>
      </div>

      {/* =========================================
          工程登録フォーム
          ========================================= */}
      <form action={createProcessAction} className={styles.form}>
        {/* 工程名 */}
        <div className={styles.formGroup}>
          <label htmlFor="processName" className={styles.label}>
            工程名
          </label>

          <input
            id="processName"
            name="processName"
            type="text"
            className={styles.input}
            required
            maxLength={100}
            placeholder="例:成形工程"
          />
        </div>

        {/* 表示順 */}
        <div className={styles.formGroup}>
          <label htmlFor="displayOrder" className={styles.label}>
            表示順
          </label>

          <input
            id="displayOrder"
            name="displayOrder"
            type="number"
            className={styles.input}
            defaultValue={0}
            min={0}
            required
          />

          <p className={styles.helpText}>
            数字が小さい工程から順番に表示します。
          </p>
        </div>

        {/* 有効 / 無効 */}
        <div className={styles.formGroup}>
          <label className={styles.checkboxLabel}>
            <input
              name="isActive"
              type="checkbox"
              defaultChecked
            />

            有効
          </label>
        </div>

        {/* =========================================
            操作ボタン
            ========================================= */}
        <div className={styles.formActions}>
          <button type="submit" className={styles.primaryButton}>
            登録
          </button>

          <Link
            href="/processes"
            className={styles.secondaryButton}
          >
            キャンセル
          </Link>
        </div>
      </form>
    </main>
  );
}

CSSを追加

processes.module.cssの末尾に追加する。

/* =========================================
   入力フォーム
   ========================================= */
.form {
  max-width: 600px;

  padding: 24px;

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

  background: #fff;
}

/* =========================================
   フォーム1項目
   ========================================= */
.formGroup {
  margin-bottom: 24px;
}

/* =========================================
   項目名
   ========================================= */
.label {
  display: block;

  margin-bottom: 8px;

  font-weight: 600;
}

/* =========================================
   テキストボックス
   ========================================= */
.input {
  box-sizing: border-box;

  width: 100%;
  padding: 10px 12px;

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

  font-size: 16px;
}

/* 入力欄を選択したとき */
.input:focus {
  outline: 2px solid #333;
  outline-offset: 1px;
}

/* =========================================
   入力補足説明
   ========================================= */
.helpText {
  margin: 6px 0 0;

  color: #666;
  font-size: 13px;
}

/* =========================================
   チェックボックス
   ========================================= */
.checkboxLabel {
  display: flex;
  align-items: center;
  gap: 8px;

  cursor: pointer;
}

/* =========================================
   フォーム下部のボタン領域
   ========================================= */
.formActions {
  display: flex;
  gap: 12px;

  margin-top: 32px;
}

/* =========================================
   登録ボタン
   ========================================= */
.primaryButton {
  padding: 10px 20px;

  border: none;
  border-radius: 6px;

  background: #222;
  color: #fff;

  font-size: 15px;
  cursor: pointer;
}

.primaryButton:hover {
  opacity: 0.85;
}

/* =========================================
   キャンセルボタン
   ========================================= */
.secondaryButton {
  display: inline-flex;
  align-items: center;

  padding: 10px 20px;

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

  color: #333;
  text-decoration: none;
}

.secondaryButton:hover {
  background: #f5f5f5;
}

工程一覧に「新規登録」ボタンを追加

processes/page.tsxを変更。
タイトル部分の下に、

  <header className={styles.header}>
    <h1 className={styles.title}>
      工程進捗
    </h1>

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

  {/* =====================================
      画面上部の操作
      ===================================== */}
  <div className={styles.topActions}>
    <Link
      href="/processes/new"
      className={styles.primaryButton}
    >
      工程を新規登録
    </Link>
  </div>

を追加する。

動作確認

工程画面を表示し、「工程を新規登録」を押し、登録する。

工程編集画面

actions.tsに更新処理を追加

src/app/(authenticated)/processes/actions.tsに、updateProcessActionを追加する。

"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import {
  createProcess,
  updateProcess,
} from "@/lib/process-api";

/**
 * 工程登録
 */
export async function createProcessAction(formData: FormData) {
  const processName =
    formData.get("processName")?.toString().trim() ?? "";

  const displayOrderText =
    formData.get("displayOrder")?.toString() ?? "0";

  const isActive = formData.get("isActive") === "on";

  const displayOrder = Number(displayOrderText);

  if (!processName) {
    throw new Error("工程名を入力してください。");
  }

  if (Number.isNaN(displayOrder)) {
    throw new Error("表示順には数値を入力してください。");
  }

  await createProcess({
    processName,
    displayOrder,
    isActive,
  });

  revalidatePath("/processes");

  redirect("/processes");
}

/**
 * 工程更新
 *
 * 編集画面から送信された内容を使用して、
 * 指定された工程を更新する。
 */
export async function updateProcessAction(formData: FormData) {
  // ---------------------------------------------------------
  // hidden項目から工程IDを取得
  // ---------------------------------------------------------
  const idText = formData.get("id")?.toString() ?? "";

  const id = Number(idText);

  if (Number.isNaN(id)) {
    throw new Error("工程IDが不正です。");
  }

  // ---------------------------------------------------------
  // フォーム入力値を取得
  // ---------------------------------------------------------
  const processName =
    formData.get("processName")?.toString().trim() ?? "";

  const displayOrderText =
    formData.get("displayOrder")?.toString() ?? "0";

  const isActive = formData.get("isActive") === "on";

  const displayOrder = Number(displayOrderText);

  // ---------------------------------------------------------
  // 入力チェック
  // ---------------------------------------------------------
  if (!processName) {
    throw new Error("工程名を入力してください。");
  }

  if (Number.isNaN(displayOrder)) {
    throw new Error("表示順には数値を入力してください。");
  }

  // ---------------------------------------------------------
  // ASP.NET Core APIへ更新要求を送信
  // ---------------------------------------------------------
  await updateProcess(id, {
    processName,
    displayOrder,
    isActive,
  });

  // ---------------------------------------------------------
  // 一覧画面のキャッシュを更新
  // ---------------------------------------------------------
  revalidatePath("/processes");

  // ---------------------------------------------------------
  // 更新後は工程一覧へ戻る
  // ---------------------------------------------------------
  redirect("/processes");
}

編集画面を作成

src/app/(authenticated)/processes/[id]/edit/page.tsx

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

import { getProcess } from "@/lib/process-api";
import { updateProcessAction } from "../../actions";

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

/**
 * URLパラメータ
 *
 * /processes/1/edit
 *              ↑
 *              id
 */
type Props = {
  params: Promise<{
    id: string;
  }>;
};

/**
 * 工程編集画面
 */
export default async function EditProcessPage({ params }: Props) {
  // ---------------------------------------------------------
  // URLから工程IDを取得
  // ---------------------------------------------------------
  const { id } = await params;

  const processId = Number(id);

  // 数値として解釈できない場合は404にする
  if (Number.isNaN(processId)) {
    notFound();
  }

  // ---------------------------------------------------------
  // APIから現在の工程情報を取得
  // ---------------------------------------------------------
  let process;

  try {
    process = await getProcess(processId);
  } catch {
    // 対象工程が存在しない場合などは404画面を表示
    notFound();
  }

  return (
    <main className={styles.container}>
      {/* =========================================
          画面タイトル
          ========================================= */}
      <div className={styles.header}>
        <h1 className={styles.title}>工程編集</h1>

        <p className={styles.description}>
          工程情報を変更します。
        </p>
      </div>

      {/* =========================================
          工程編集フォーム
          ========================================= */}
      <form
        action={updateProcessAction}
        className={styles.form}
      >
        {/* 更新対象の工程ID */}
        <input
          type="hidden"
          name="id"
          value={process.id}
        />

        {/* 工程名 */}
        <div className={styles.formGroup}>
          <label
            htmlFor="processName"
            className={styles.label}
          >
            工程名
          </label>

          <input
            id="processName"
            name="processName"
            type="text"
            className={styles.input}
            defaultValue={process.processName}
            required
            maxLength={100}
          />
        </div>

        {/* 表示順 */}
        <div className={styles.formGroup}>
          <label
            htmlFor="displayOrder"
            className={styles.label}
          >
            表示順
          </label>

          <input
            id="displayOrder"
            name="displayOrder"
            type="number"
            className={styles.input}
            defaultValue={process.displayOrder}
            min={0}
            required
          />

          <p className={styles.helpText}>
            数字が小さい工程から順番に表示します。
          </p>
        </div>

        {/* 有効 / 無効 */}
        <div className={styles.formGroup}>
          <label className={styles.checkboxLabel}>
            <input
              name="isActive"
              type="checkbox"
              defaultChecked={process.isActive}
            />

            有効
          </label>
        </div>

        {/* =========================================
            操作ボタン
            ========================================= */}
        <div className={styles.formActions}>
          <button
            type="submit"
            className={styles.primaryButton}
          >
            更新
          </button>

          <Link
            href="/processes"
            className={styles.secondaryButton}
          >
            キャンセル
          </Link>
        </div>
      </form>
    </main>
  );
}

工程一覧に「編集」リンクを追加

processes/page.tsxのprocessesWithProgress.map()を変更する。

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

    // ---------------------------------
    // 完了率を計算
    // ---------------------------------
    const completionRate =
      progress.total === 0
        ? 0
        : Math.round(
            (
              progress.completed /
              progress.total
            ) * 100
          );

    return (
      <div
        key={process.id}
        className={styles.card}
      >
        {/* =================================
            工程進捗画面へのリンク
            ================================= */}
        <Link
          href={
            `/processes/${process.id}/tasks`
          }
          className={styles.cardMain}
        >
          {/* 工程名 */}
          <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 className={styles.cardActions}>
          <Link
            href={
              `/processes/${process.id}/edit`
            }
            className={styles.editLink}
          >
            編集
          </Link>
        </div>
      </div>
    );
  }
)}

processes.module.cssも変更

主な変更点は、.cardからpaddingを外し、新しく.cardMain、.cardActions、.editLinkを追加した点。

/* =========================================
   画面全体
   ========================================= */
.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 {
  /*
   * カード内部を
   *
   * ・工程進捗部分
   * ・操作部分
   *
   * に分けるため、
   * paddingはここでは設定しない。
   */
  overflow: hidden;

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

  background-color: white;

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

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

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

/* =========================================
   工程カードのメイン部分
   ========================================= */
.cardMain {
  /*
   * 工程名や進捗を表示する部分。
   *
   * この部分をクリックすると
   * /processes/{id}/tasks
   * へ移動する。
   */
  display: block;

  padding: 20px;

  color: inherit;
  text-decoration: none;
}

/* 工程情報部分にマウスを乗せたとき */
.cardMain:hover {
  background-color: #f8f8f8;
}

/* =========================================
   工程名
   ========================================= */
.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;
}

/* =========================================
   工程カード下部の操作領域
   ========================================= */
.cardActions {
  /*
   * 編集ボタンを表示する領域。
   *
   * 工程進捗へのリンクとは分離しているため、
   * 「編集」を押しても作業一覧には移動しない。
   */
  display: flex;

  justify-content: flex-end;

  padding: 12px 20px;

  border-top: 1px solid #eee;

  background-color: #fafafa;
}

/* =========================================
   編集ボタン
   ========================================= */
.editLink {
  display: inline-block;

  padding: 6px 14px;

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

  background-color: white;

  color: #333;
  text-decoration: none;

  white-space: nowrap;
}

/* 編集ボタンにマウスを乗せたとき */
.editLink:hover {
  background-color: #eee;
}

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

/* =========================================
   入力フォーム
   ========================================= */
.form {
  max-width: 600px;

  padding: 24px;

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

  background: #fff;
}

/* =========================================
   フォーム1項目
   ========================================= */
.formGroup {
  margin-bottom: 24px;
}

/* =========================================
   項目名
   ========================================= */
.label {
  display: block;

  margin-bottom: 8px;

  font-weight: 600;
}

/* =========================================
   テキストボックス
   ========================================= */
.input {
  box-sizing: border-box;

  width: 100%;
  padding: 10px 12px;

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

  font-size: 16px;
}

/* 入力欄を選択したとき */
.input:focus {
  outline: 2px solid #333;
  outline-offset: 1px;
}

/* =========================================
   入力補足説明
   ========================================= */
.helpText {
  margin: 6px 0 0;

  color: #666;
  font-size: 13px;
}

/* =========================================
   チェックボックス
   ========================================= */
.checkboxLabel {
  display: flex;

  align-items: center;

  gap: 8px;

  cursor: pointer;
}

/* =========================================
   フォーム下部のボタン領域
   ========================================= */
.formActions {
  display: flex;

  gap: 12px;

  margin-top: 32px;
}

/* =========================================
   登録・更新ボタン
   ========================================= */
.primaryButton {
  /*
   * buttonだけでなくLinkでも使用するため、
   * inline-blockを指定している。
   */
  display: inline-block;

  padding: 10px 20px;

  border: none;
  border-radius: 6px;

  background: #222;
  color: #fff;

  font-size: 15px;

  text-decoration: none;

  cursor: pointer;
}

.primaryButton:hover {
  opacity: 0.85;
}

/* =========================================
   キャンセルボタン
   ========================================= */
.secondaryButton {
  display: inline-flex;

  align-items: center;

  padding: 10px 20px;

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

  color: #333;
  text-decoration: none;
}

.secondaryButton:hover {
  background: #f5f5f5;
}

/* =========================================
   画面上部の操作領域
   ========================================= */
.topActions {
  display: flex;

  align-items: center;

  /* 一覧との間に余白を作る */
  margin-bottom: 20px;
}

/* =========================================
   スマートフォン表示
   ========================================= */
@media (max-width: 600px) {
  .container {
    padding: 20px 16px;
  }

  .title {
    font-size: 24px;
  }

  /* 工程カードを1列表示にする */
  .grid {
    grid-template-columns: 1fr;
  }

  /*
   * .card自体にはpaddingを設定せず、
   * カードのメイン部分に設定する。
   */
  .cardMain {
    padding: 16px;
  }

  /* カード下部の操作領域も少し狭くする */
  .cardActions {
    padding: 12px 16px;
  }

  .processName {
    font-size: 18px;
  }

  /* ステータス件数を縦方向にも折り返せるようにする */
  .statusList {
    gap: 8px 12px;
  }

  /* 完了率部分 */
  .progressArea {
    gap: 8px;
  }

  /*
   * スマートフォンではフォームの余白を少し小さくする。
   */
  .form {
    padding: 20px 16px;
  }

  /*
   * 登録・キャンセルボタンが横幅不足になった場合に
   * 折り返せるようにする。
   */
  .formActions {
    flex-wrap: wrap;
  }
}

フェーズ5-18:作業の新規登録画面

task-api.tsに登録処理を追加

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

/**
 * 作業登録時にAPIへ送信するデータ。
 */
export type CreateTaskRequest = {
  taskName: string;
  processId: number;
  status: WorkTaskStatus;
  plannedDate: string | null;
};

/**
 * 作業を新規登録する。
 */
export async function createTask(
  request: CreateTaskRequest
): Promise<TaskDto> {
  const cookieStore =
    await cookies();

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

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

  const apiBaseUrl =
    process.env.API_BASE_URL;

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

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

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

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

        body: JSON.stringify(request),
      }
    );

  if (!response.ok) {
    throw new Error(
      "作業の登録に失敗しました。"
    );
  }

  return response.json();
}

Server Actionを変更

src/app/(authenticated)/processes/[id]/tasks/actions.ts

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

を、

import {
  redirect,
} from "next/navigation";

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

次の処理を追加する。

/**
 * 作業を新規登録するServer Action。
 */
export async function createTaskAction(
  formData: FormData
) {
  // -----------------------------------------------
  // フォームから入力値を取得
  // -----------------------------------------------

  const processIdText =
    formData
      .get("processId")
      ?.toString() ?? "";

  const taskName =
    formData
      .get("taskName")
      ?.toString()
      .trim() ?? "";

  const plannedDateText =
    formData
      .get("plannedDate")
      ?.toString() ?? "";

  // -----------------------------------------------
  // 工程IDを数値へ変換
  // -----------------------------------------------

  const processId =
    Number(processIdText);

  if (Number.isNaN(processId)) {
    throw new Error(
      "工程IDが不正です。"
    );
  }

  // -----------------------------------------------
  // 作業名をチェック
  // -----------------------------------------------

  if (!taskName) {
    throw new Error(
      "作業名を入力してください。"
    );
  }

  // -----------------------------------------------
  // APIへ登録要求を送信
  // -----------------------------------------------
  //
  // 新規作業は「未着手」で登録する。
  //

  await createTask({
    taskName,
    processId,

    status: "NotStarted",

    // 日付未入力の場合はnullを送る
    plannedDate:
      plannedDateText === ""
        ? null
        : plannedDateText,
  });

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

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

  // -----------------------------------------------
  // 登録後は作業一覧へ戻る
  // -----------------------------------------------

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

新規登録画面を作成

src/app/(authenticated)/processes/[id]/tasks/new/page.tsx

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

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

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

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

/**
 * URLパラメータ。
 *
 * /processes/1/tasks/new
 *            ↑
 *         processId
 */
type Props = {
  params: Promise<{
    id: string;
  }>;
};

/**
 * 作業新規登録画面。
 */
export default async function NewTaskPage({
  params,
}: Props) {
  // -----------------------------------------------
  // URLから工程IDを取得
  // -----------------------------------------------

  const { id } =
    await params;

  const processId =
    Number(id);

  if (Number.isNaN(processId)) {
    notFound();
  }

  // -----------------------------------------------
  // 工程情報を取得
  // -----------------------------------------------

  let process;

  try {
    process =
      await getProcess(processId);
  } catch {
    notFound();
  }

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

      {/* =====================================
          画面上部
          ===================================== */}
      <header className={styles.header}>
        <h1 className={styles.title}>
          作業登録
        </h1>

        <p className={styles.description}>
          {process.processName}
          に新しい作業を登録します。
        </p>
      </header>

      {/* =====================================
          作業登録フォーム
          ===================================== */}
      <form
        action={createTaskAction}
        className={styles.form}
      >

        {/* 登録対象の工程ID */}
        <input
          type="hidden"
          name="processId"
          value={process.id}
        />

        {/* =================================
            作業名
            ================================= */}
        <div className={styles.formGroup}>
          <label
            htmlFor="taskName"
            className={styles.label}
          >
            作業名
          </label>

          <input
            id="taskName"
            name="taskName"
            type="text"
            className={styles.input}
            maxLength={200}
            required
            placeholder="例:材料準備"
          />
        </div>

        {/* =================================
            予定日
            ================================= */}
        <div className={styles.formGroup}>
          <label
            htmlFor="plannedDate"
            className={styles.label}
          >
            予定日
          </label>

          <input
            id="plannedDate"
            name="plannedDate"
            type="date"
            className={styles.input}
          />

          <p className={styles.helpText}>
            予定日は未入力でも登録できます。
          </p>
        </div>

        {/* =================================
            初期ステータス
            ================================= */}
        <div className={styles.formGroup}>
          <span className={styles.label}>
            ステータス
          </span>

          <p className={styles.helpText}>
            新規登録時は「未着手」で登録されます。
          </p>
        </div>

        {/* =================================
            操作ボタン
            ================================= */}
        <div className={styles.formActions}>
          <button
            type="submit"
            className={styles.primaryButton}
          >
            登録
          </button>

          <Link
            href={
              `/processes/${process.id}/tasks`
            }
            className={styles.secondaryButton}
          >
            キャンセル
          </Link>
        </div>

      </form>
    </main>
  );
}

tasks.module.cssに追加

tasks.module.cssに追加

/* =========================================
   入力フォーム
   ========================================= */
.form {
  max-width: 600px;

  padding: 24px;

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

  background-color: #fff;
}

/* =========================================
   フォーム1項目
   ========================================= */
.formGroup {
  margin-bottom: 24px;
}

/* =========================================
   項目名
   ========================================= */
.label {
  display: block;

  margin-bottom: 8px;

  font-weight: 600;
}

/* =========================================
   入力欄
   ========================================= */
.input {
  box-sizing: border-box;

  width: 100%;
  padding: 10px 12px;

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

  font-size: 16px;
}

.input:focus {
  outline: 2px solid #333;
  outline-offset: 1px;
}

/* =========================================
   補足説明
   ========================================= */
.helpText {
  margin: 6px 0 0;

  color: #666;

  font-size: 13px;
}

/* =========================================
   ボタン領域
   ========================================= */
.formActions {
  display: flex;

  flex-wrap: wrap;

  gap: 12px;

  margin-top: 32px;
}

/* =========================================
   登録ボタン
   ========================================= */
.primaryButton {
  padding: 10px 20px;

  border: none;
  border-radius: 6px;

  background-color: #222;
  color: #fff;

  font-size: 15px;

  cursor: pointer;
}

.primaryButton:hover {
  opacity: 0.85;
}

/* =========================================
   キャンセル
   ========================================= */
.secondaryButton {
  display: inline-flex;

  align-items: center;

  padding: 10px 20px;

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

  color: #333;
  text-decoration: none;
}

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

/* =========================================
   画面上部の操作領域
   ========================================= */
.topActions {
  display: flex;

  align-items: center;

  /* 作業一覧との間に余白を作る */
  margin-bottom: 20px;
}

作業一覧へ「新規登録」ボタンを追加

src/app/(authenticated)/processes/[id]/tasks/page.tsx
の画面上部に追加する。

<header className={styles.header}>
  <h1 className={styles.title}>
    {process.processName}
  </h1>

  <p className={styles.description}>
    作業一覧と進捗を確認できます。
  </p>
</header>

{/* =====================================
    画面上部の操作
    ===================================== */}
<div className={styles.topActions}>
  <Link
    href={
      `/processes/${processId}/tasks/new`
    }
    className={styles.primaryButton}
  >
    作業を新規登録
  </Link>
</div>

動作確認

作業一覧で「作業を新規登録」が動作することを確認。

フェーズ5-19:作業編集画面

task-api.tsに1件取得・更新を追加

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

/**
 * 作業更新時にAPIへ送信するデータ。
 */
export type UpdateTaskRequest = {
  taskName: string;
  processId: number;
  status: WorkTaskStatus;
  plannedDate: string | null;
};

/**
 * 指定したIDの作業を1件取得する。
 *
 * 作業編集画面の初期表示で使用する。
 */
export async function getTask(
  id: number
): Promise<TaskDto> {
  const cookieStore =
    await cookies();

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

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

  const apiBaseUrl =
    process.env.API_BASE_URL;

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

  const response =
    await fetch(
      `${apiBaseUrl}/api/Tasks/${id}`,
      {
        headers: {
          Authorization:
            `Bearer ${accessToken}`,
        },

        cache: "no-store",
      }
    );

  if (!response.ok) {
    throw new Error(
      "作業情報の取得に失敗しました。"
    );
  }

  return response.json();
}

/**
 * 作業情報を更新する。
 */
export async function updateTask(
  id: number,
  request: UpdateTaskRequest
): Promise<void> {
  const cookieStore =
    await cookies();

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

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

  const apiBaseUrl =
    process.env.API_BASE_URL;

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

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

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

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

        body: JSON.stringify(request),
      }
    );

  if (!response.ok) {
    throw new Error(
      "作業の更新に失敗しました。"
    );
  }
}

actions.tsに更新処理を追加

src/app/(authenticated)/processes/[id]/tasks/actions.ts
importにupdateTaskを追加。

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

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

次を追加。

/**
 * 作業情報を更新するServer Action。
 */
export async function updateTaskAction(
  formData: FormData
) {
  // -----------------------------------------------
  // フォームからIDを取得
  // -----------------------------------------------

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

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

  if (Number.isNaN(taskId)) {
    throw new Error(
      "作業IDが不正です。"
    );
  }

  if (Number.isNaN(processId)) {
    throw new Error(
      "工程IDが不正です。"
    );
  }

  // -----------------------------------------------
  // 入力値を取得
  // -----------------------------------------------

  const taskName =
    formData
      .get("taskName")
      ?.toString()
      .trim() ?? "";

  const status =
    formData
      .get("status")
      ?.toString() ?? "";

  const plannedDateText =
    formData
      .get("plannedDate")
      ?.toString() ?? "";

  // -----------------------------------------------
  // 入力チェック
  // -----------------------------------------------

  if (!taskName) {
    throw new Error(
      "作業名を入力してください。"
    );
  }

  if (
    status !== "NotStarted" &&
    status !== "InProgress" &&
    status !== "Completed"
  ) {
    throw new Error(
      "ステータスが不正です。"
    );
  }

  // -----------------------------------------------
  // APIへ更新要求を送信
  // -----------------------------------------------

  await updateTask(
    taskId,
    {
      taskName,
      processId,

      status,

      plannedDate:
        plannedDateText === ""
          ? null
          : plannedDateText,
    }
  );

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

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

  // 工程進捗にも影響するので、
  // 工程一覧も再取得させる
  revalidatePath(
    "/processes"
  );

  // -----------------------------------------------
  // 作業一覧へ戻る
  // -----------------------------------------------

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

編集画面を作成

src/app/(authenticated)/processes/[id]/tasks/[taskId]/edit/page.tsx

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

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

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

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

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

/**
 * URLパラメータ。
 *
 * /processes/1/tasks/10/edit
 *            ↑       ↑
 *            id      taskId
 */
type Props = {
  params: Promise<{
    id: string;
    taskId: string;
  }>;
};

/**
 * 作業編集画面。
 */
export default async function EditTaskPage({
  params,
}: Props) {
  // --------------------------------------------------
  // URLパラメータを取得
  // --------------------------------------------------
  const {
    id,
    taskId,
  } = await params;

  const processId =
    Number(id);

  const targetTaskId =
    Number(taskId);

  // --------------------------------------------------
  // IDの妥当性を確認
  // --------------------------------------------------
  if (
    !Number.isInteger(processId) ||
    processId <= 0 ||
    !Number.isInteger(targetTaskId) ||
    targetTaskId <= 0
  ) {
    notFound();
  }

  // --------------------------------------------------
  // 工程情報と作業情報を取得
  // --------------------------------------------------
  const [
    process,
    task,
  ] = await Promise.all([
    getProcess(processId),
    getTask(targetTaskId),
  ]);

  // --------------------------------------------------
  // URLの工程IDと、
  // 作業が所属する工程IDが一致するか確認
  // --------------------------------------------------
  if (
    task.processId !== processId
  ) {
    notFound();
  }

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

      {/* =====================================
          画面タイトル
          ===================================== */}
      <header className={styles.header}>
        <h1 className={styles.title}>
          作業編集
        </h1>

        <p className={styles.description}>
          {process.processName}
          の作業情報を変更します。
        </p>
      </header>

      {/* =====================================
          作業編集フォーム
          ===================================== */}
      <form
        action={updateTaskAction}
        className={styles.form}
      >
        {/* 更新対象の作業ID */}
        <input
          type="hidden"
          name="taskId"
          value={task.id}
        />

        {/* 所属工程ID */}
        <input
          type="hidden"
          name="processId"
          value={process.id}
        />

        {/* =================================
            作業名
            ================================= */}
        <div className={styles.formGroup}>
          <label
            htmlFor="taskName"
            className={styles.label}
          >
            作業名
          </label>

          <input
            id="taskName"
            name="taskName"
            type="text"
            className={styles.input}
            defaultValue={task.taskName}
            maxLength={200}
            required
          />
        </div>

        {/* =================================
            予定日
            ================================= */}
        <div className={styles.formGroup}>
          <label
            htmlFor="plannedDate"
            className={styles.label}
          >
            予定日
          </label>

          <input
            id="plannedDate"
            name="plannedDate"
            type="date"
            className={styles.input}
            defaultValue={
              task.plannedDate
                ? task.plannedDate.substring(
                    0,
                    10
                  )
                : ""
            }
          />
        </div>

        {/* =================================
            ステータス
            ================================= */}
        <div className={styles.formGroup}>
          <label
            htmlFor="status"
            className={styles.label}
          >
            ステータス
          </label>

          <select
            id="status"
            name="status"
            className={styles.input}
            defaultValue={task.status}
          >
            <option value="NotStarted">
              未着手
            </option>

            <option value="InProgress">
              作業中
            </option>

            <option value="Completed">
              完了
            </option>
          </select>
        </div>

        {/* =================================
            操作ボタン
            ================================= */}
        <div className={styles.formActions}>
          <button
            type="submit"
            className={styles.primaryButton}
          >
            更新
          </button>

          <Link
            href={`/processes/${processId}/tasks`}
            className={styles.secondaryButton}
          >
            キャンセル
          </Link>
        </div>

      </form>

    </main>
  );
}

作業一覧に「編集」を追加

tasks/page.tsx
操作を追加。

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

ステータス更新用の

の直後に、次のを追加。
{/* =================================
    操作
    ================================= */}
<td>
  <Link
    href={
      `/processes/${processId}/tasks/${task.id}/edit`
    }
    className={styles.editLink}
  >
    編集
  </Link>
</td>

tasks.module.cssに次を追加。

/* =========================================
   編集ボタン
   ========================================= */
.editLink {
  display: inline-block;

  padding: 6px 12px;

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

  background-color: #fff;

  color: #333;
  text-decoration: none;

  white-space: nowrap;
}

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

フェーズ5-20:作業削除機能

task-api.tsに削除処理を追加

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

/**
 * 指定した作業を削除する。
 */
export async function deleteTask(
  id: number
): Promise<void> {
  const cookieStore =
    await cookies();

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

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

  const apiBaseUrl =
    process.env.API_BASE_URL;

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

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

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

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

tasks/actions.tsに削除用Server Actionを追加

importにdeleteTaskを追加する。

import {
  updateTask,
  createTask,
  deleteTask,
} from "@/lib/task-api";

末尾に追加。

/**
 * 作業を削除するServer Action。
 */
export async function deleteTaskAction(
  formData: FormData
) {
  // -----------------------------------------------
  // フォームからIDを取得
  // -----------------------------------------------
  const taskId =
    Number(
      formData.get("taskId")
    );

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

  // -----------------------------------------------
  // IDチェック
  // -----------------------------------------------
  if (
    !Number.isInteger(taskId) ||
    taskId <= 0
  ) {
    throw new Error(
      "作業IDが不正です。"
    );
  }

  if (
    !Number.isInteger(processId) ||
    processId <= 0
  ) {
    throw new Error(
      "工程IDが不正です。"
    );
  }

  // -----------------------------------------------
  // APIで削除
  // -----------------------------------------------
  await deleteTask(taskId);

  // -----------------------------------------------
  // 一覧を再取得
  // -----------------------------------------------
  revalidatePath(
    `/processes/${processId}/tasks`
  );

  // 工程進捗にも影響するので更新
  revalidatePath("/processes");
}

tasks/page.tsxに削除ボタンを追加

importを変更する。

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

現在の操作列の、

<td>
  <Link
    href={
      `/processes/${processId}/tasks/${task.id}/edit`
    }
    className={styles.editLink}
  >
    編集
  </Link>
</td>

を、

<td>
  <div className={styles.operationGroup}>
    {/* 作業編集 */}
    <Link
      href={
        `/processes/${processId}/tasks/${task.id}/edit`
      }
      className={styles.editLink}
    >
      編集
    </Link>

    {/* 作業削除 */}
    <form action={deleteTaskAction}>
      <input
        type="hidden"
        name="taskId"
        value={task.id}
      />

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

      <button
        type="submit"
        className={styles.deleteButton}
      >
        削除
      </button>
    </form>
  </div>
</td>

に変更する。

tasks.module.cssに追加

/* =========================================
   操作ボタン領域
   ========================================= */
.operationGroup {
  display: flex;

  align-items: center;

  gap: 8px;

  white-space: nowrap;
}

/* =========================================
   削除ボタン
   ========================================= */
.deleteButton {
  padding: 6px 12px;

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

  background-color: #fff;

  color: #c62828;

  font-size: 14px;

  cursor: pointer;
}

.deleteButton:hover {
  background-color: #fff5f5;
}

フェーズ5-21:削除確認ダイアログ

DeleteTaskButton.tsxを作成

src/app/(authenticated)/processes/[id]/tasks/DeleteTaskButton.tsx

"use client";

import {
  useState,
} from "react";

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

type Props = {
  taskId: number;
  processId: number;

  // 削除対象が分かるように
  // 作業名も受け取る
  taskName: string;

  // Server Action
  deleteAction: (
    formData: FormData
  ) => void | Promise<void>;
};

/**
 * 作業削除ボタン。
 *
 * 削除前に確認ダイアログを表示するため、
 * Client Componentとして実装する。
 */
export default function DeleteTaskButton({
  taskId,
  processId,
  taskName,
  deleteAction,
}: Props) {
  const [
    isOpen,
    setIsOpen,
  ] = useState(false);

  return (
    <>
      {/* =====================================
          削除ボタン
          ===================================== */}
      <button
        type="button"
        className={styles.deleteButton}
        onClick={() => {
          setIsOpen(true);
        }}
      >
        削除
      </button>

      {/* =====================================
          削除確認ダイアログ
          ===================================== */}
      {isOpen && (
        <div
          className={
            styles.dialogOverlay
          }
        >
          <div
            className={
              styles.dialog
            }
          >
            <h2
              className={
                styles.dialogTitle
              }
            >
              作業を削除
            </h2>

            <p
              className={
                styles.dialogMessage
              }
            >
              「{taskName}」を削除しますか?
            </p>

            <p
              className={
                styles.dialogWarning
              }
            >
              この操作は元に戻せません。
            </p>

            <div
              className={
                styles.dialogActions
              }
            >
              {/* キャンセル */}
              <button
                type="button"
                className={
                  styles.secondaryButton
                }
                onClick={() => {
                  setIsOpen(false);
                }}
              >
                キャンセル
              </button>

              {/* 実際の削除処理 */}
              <form
                action={deleteAction}
              >
                <input
                  type="hidden"
                  name="taskId"
                  value={taskId}
                />

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

                <button
                  type="submit"
                  className={
                    styles.deleteButton
                  }
                >
                  削除する
                </button>
              </form>
            </div>
          </div>
        </div>
      )}
    </>
  );
}

tasks/page.tsxにimport

import DeleteTaskButton
  from "./DeleteTaskButton";

現在の削除部分、

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

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

  <button
    type="submit"
    className={styles.deleteButton}
  >
    削除
  </button>
</form>

を削除して、代わりに、

<DeleteTaskButton
  taskId={task.id}
  processId={processId}
  taskName={task.taskName}
  deleteAction={deleteTaskAction}
/>

tasks.module.cssに追加

/* =========================================
   削除確認ダイアログの背景
   ========================================= */
.dialogOverlay {
  position: fixed;

  inset: 0;

  display: flex;
  align-items: center;
  justify-content: center;

  padding: 16px;

  background-color:
    rgba(0, 0, 0, 0.4);

  z-index: 1000;
}

/* =========================================
   削除確認ダイアログ
   ========================================= */
.dialog {
  box-sizing: border-box;

  width: 100%;
  max-width: 420px;

  padding: 24px;

  border-radius: 8px;

  background-color: #fff;

  box-shadow:
    0 8px 24px
    rgba(0, 0, 0, 0.2);
}

/* =========================================
   ダイアログタイトル
   ========================================= */
.dialogTitle {
  margin: 0 0 16px;

  font-size: 20px;
}

/* =========================================
   ダイアログ本文
   ========================================= */
.dialogMessage {
  margin: 0 0 8px;
}

/* =========================================
   削除警告
   ========================================= */
.dialogWarning {
  margin: 0;

  color: #c62828;

  font-size: 13px;
}

/* =========================================
   ダイアログ下部のボタン
   ========================================= */
.dialogActions {
  display: flex;

  justify-content: flex-end;

  gap: 12px;

  margin-top: 24px;
}

フェーズ5-22:無効工程管理画面

再有効化用Server Action

src/app/(authenticated)/processes/actions.tsに追加する。

import {
  createProcess,
  updateProcess,
  getProcess,
} from "@/lib/process-api";

末尾に追加する。

/**
 * 無効化されている工程を再有効化する。
 */
export async function reactivateProcessAction(
  formData: FormData
) {
  // ---------------------------------------------------------
  // 工程IDを取得
  // ---------------------------------------------------------
  const processId =
    Number(
      formData.get("processId")
    );

  // ---------------------------------------------------------
  // 工程IDをチェック
  // ---------------------------------------------------------
  if (
    !Number.isInteger(processId) ||
    processId <= 0
  ) {
    throw new Error(
      "工程IDが不正です。"
    );
  }

  // ---------------------------------------------------------
  // 現在の工程情報を取得
  // ---------------------------------------------------------
  //
  // PUT APIでは工程名や表示順も必要なので、
  // 現在値をAPIから取得する。
  //
  const process =
    await getProcess(processId);

  // ---------------------------------------------------------
  // isActiveだけtrueにして更新
  // ---------------------------------------------------------
  await updateProcess(
    processId,
    {
      processName:
        process.processName,

      displayOrder:
        process.displayOrder,

      isActive: true,
    }
  );

  // ---------------------------------------------------------
  // 工程一覧を再取得させる
  // ---------------------------------------------------------
  revalidatePath(
    "/processes"
  );

  // 無効工程一覧も再取得させる
  revalidatePath(
    "/processes/inactive"
  );
}

無効工程管理画面を作成

src/app/(authenticated)/processes/inactive/page.tsx

import Link from "next/link";

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

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

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

/**
 * 無効工程管理画面。
 *
 * isActive = false の工程だけを表示し、
 * 再有効化できるようにする。
 */
export default async function InactiveProcessesPage() {
  // ---------------------------------------------------------
  // 工程一覧を取得
  // ---------------------------------------------------------
  const processes =
    await getProcesses();

  // ---------------------------------------------------------
  // 無効な工程だけを抽出
  // ---------------------------------------------------------
  const inactiveProcesses =
    processes.filter(
      (process) =>
        !process.isActive
    );

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

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

        <p className={styles.description}>
          無効化された工程の確認と
          再有効化ができます。
        </p>
      </header>

      {/* =====================================
          無効工程一覧
          ===================================== */}
      {inactiveProcesses.length === 0 ? (
        <div className={styles.emptyMessage}>
          無効化されている工程はありません。
        </div>
      ) : (
        <div
          className={
            styles.processTableWrapper
          }
        >
          <table
            className={
              styles.processTable
            }
          >
            <thead>
              <tr>
                <th>ID</th>

                <th>
                  工程名
                </th>

                <th>
                  表示順
                </th>

                <th>
                  操作
                </th>
              </tr>
            </thead>

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

                    {/* 工程名 */}
                    <td>
                      {process.processName}
                    </td>

                    {/* 表示順 */}
                    <td>
                      {process.displayOrder}
                    </td>

                    {/* 操作 */}
                    <td>
                      <div
                        className={
                          styles.inactiveActions
                        }
                      >
                        {/* 工程編集 */}
                        <Link
                          href={
                            `/processes/${process.id}/edit`
                          }
                          className={
                            styles.editLink
                          }
                        >
                          編集
                        </Link>

                        {/* 再有効化 */}
                        <form
                          action={
                            reactivateProcessAction
                          }
                        >
                          <input
                            type="hidden"
                            name="processId"
                            value={
                              process.id
                            }
                          />

                          <button
                            type="submit"
                            className={
                              styles.reactivateButton
                            }
                          >
                            再有効化
                          </button>
                        </form>
                      </div>
                    </td>
                  </tr>
                )
              )}
            </tbody>
          </table>
        </div>
      )}

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

    </main>
  );
}

CSSを追加

processes.module.cssの末尾に追加する。

/* =========================================
   無効工程一覧のテーブル領域
   ========================================= */
.processTableWrapper {
  overflow-x: auto;

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

/* =========================================
   無効工程一覧テーブル
   ========================================= */
.processTable {
  width: 100%;

  border-collapse: collapse;

  background-color: #fff;
}

/* ヘッダー */
.processTable th {
  padding: 14px 16px;

  background-color: #f5f5f5;

  text-align: left;

  white-space: nowrap;
}

/* データ */
.processTable td {
  padding: 14px 16px;

  border-top: 1px solid #eee;

  white-space: nowrap;
}

/* =========================================
   無効工程の操作領域
   ========================================= */
.inactiveActions {
  display: flex;

  align-items: center;

  gap: 8px;
}

/* =========================================
   再有効化ボタン
   ========================================= */
.reactivateButton {
  padding: 6px 12px;

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

  background-color: #222;
  color: #fff;

  font-size: 14px;

  cursor: pointer;

  white-space: nowrap;
}

.reactivateButton:hover {
  opacity: 0.85;
}

/* =========================================
   データなし
   ========================================= */
.emptyMessage {
  padding: 24px;

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

  background-color: #fff;

  color: #666;
}

.topActionにgapを追加。

/* =========================================
   画面上部の操作領域
   ========================================= */
.topActions {
  display: flex;

  align-items: center;

  gap: 12px;

  margin-bottom: 20px;
}

スマートフォン表示用の部分に追加。

@media (max-width: 600px) {
  .topActions {
    flex-wrap: wrap;
  }
}

工程一覧に「無効工程の管理」を追加

processes/page.tsxを変更。

<div className={styles.topActions}>
  {/* 工程新規登録 */}
  <Link
    href="/processes/new"
    className={styles.primaryButton}
  >
    工程を新規登録
  </Link>

  {/* 無効工程管理 */}
  <Link
    href="/processes/inactive"
    className={styles.secondaryButton}
  >
    無効工程の管理
  </Link>
</div>
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?