前の記事
フェーズ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>