前の記事
次の記事
フェーズ5-6:工程一覧画面を作成して、GET /api/Processes をNext.jsから取得する
工程一覧取得処理を作る
src/lib/process-api.ts
import { cookies } from "next/headers";
/**
* ASP.NET Core側のProcessDtoに対応する型。
*
* C#:
* ProcessDto
*
* JSONではcamelCaseになるため、
* TypeScript側もcamelCaseで定義する。
*/
export type ProcessDto = {
id: number;
processName: string;
displayOrder: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
};
/**
* 工程一覧を取得する。
*
* ブラウザから直接APIを呼ぶのではなく、
* Next.jsサーバーからASP.NET Core APIを呼び出す。
*/
export async function getProcesses(): Promise<ProcessDto[]> {
// --------------------------------------------------
// CookieからJWTを取得
// --------------------------------------------------
const cookieStore = await cookies();
const accessToken =
cookieStore.get("access_token")?.value;
// JWTが存在しない場合
if (!accessToken) {
throw new Error("アクセストークンがありません。");
}
// --------------------------------------------------
// APIのURLを取得
// --------------------------------------------------
const apiBaseUrl = process.env.API_BASE_URL;
if (!apiBaseUrl) {
throw new Error(
"API_BASE_URLが設定されていません。"
);
}
// --------------------------------------------------
// ASP.NET Core APIを呼び出す
// --------------------------------------------------
const response = await fetch(
`${apiBaseUrl}/api/Processes`,
{
method: "GET",
headers: {
// JWTをAuthorizationヘッダーに設定する
Authorization: `Bearer ${accessToken}`,
},
// 工程情報は最新状態を取得したいため、
// Next.js側ではキャッシュしない。
cache: "no-store",
}
);
// --------------------------------------------------
// HTTPエラー
// --------------------------------------------------
if (!response.ok) {
throw new Error(
`工程一覧の取得に失敗しました。status=${response.status}`
);
}
// --------------------------------------------------
// JSON → ProcessDto[]
// --------------------------------------------------
const data =
(await response.json()) as ProcessDto[];
return data;
}
工程一覧ページを作る
src/app/processes/page.tsx
import Link from "next/link";
import { getProcesses } from "@/lib/process-api";
/**
* 工程一覧画面。
*
* Server Componentなので、
* コンポーネント内で直接awaitを使用できる。
*/
export default async function ProcessesPage() {
// ASP.NET Core APIから工程一覧を取得する
const processes = await getProcesses();
return (
<main>
<h1>工程一覧</h1>
{/* 工程が1件も登録されていない場合 */}
{processes.length === 0 ? (
<p>工程が登録されていません。</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>工程名</th>
<th>表示順</th>
<th>有効</th>
</tr>
</thead>
<tbody>
{processes.map((process) => (
<tr key={process.id}>
<td>{process.id}</td>
<td>
{process.processName}
</td>
<td>
{process.displayOrder}
</td>
<td>
{process.isActive
? "有効"
: "無効"}
</td>
</tr>
))}
</tbody>
</table>
)}
<p>
<Link href="/">
トップへ戻る
</Link>
</p>
</main>
);
}
トップ画面から工程一覧へ移動できるようにする
src/app/page.tsxを変更。
proxy.tsも変更
src/proxy.ts
/processesに認証ガードをつける。
export const config = {
matcher: ["/", "/login", "/processes/:path*"],
};
動作確認
dockerでAPI起動。
Next.js起動。
npm.cmd run dev
ブラウザで、
http://localhost:3000/login
からログインする。
フェーズ5-7:工程を選択して、その工程の作業一覧を表示する
Task APIの取得処理を作成
src/lib/task-api.ts
import { cookies } from "next/headers";
/**
* ASP.NET Core側のTaskDtoに対応する型。
*/
export type TaskDto = {
id: number;
taskName: string;
status: string;
plannedDate: string | null;
createdAt: string;
updatedAt: string;
// 所属している工程
processId: number;
processName: string | null;
};
/**
* GET /api/Tasks のページングレスポンス。
*
* ASP.NET Core側では作業一覧を
* itemsの中に格納して返している。
*/
type TaskPagedResponse = {
items: TaskDto[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
};
/**
* 指定した工程に所属する作業一覧を取得する。
*
* @param processId 工程ID
*/
export async function getTasksByProcessId(
processId: number
): Promise<TaskDto[]> {
// -----------------------------------------------
// CookieからJWTを取得
// -----------------------------------------------
const cookieStore = await cookies();
const accessToken =
cookieStore.get("access_token")?.value;
if (!accessToken) {
throw new Error(
"アクセストークンがありません。"
);
}
// -----------------------------------------------
// API接続先
// -----------------------------------------------
const apiBaseUrl = process.env.API_BASE_URL;
if (!apiBaseUrl) {
throw new Error(
"API_BASE_URLが設定されていません。"
);
}
// -----------------------------------------------
// ASP.NET Core APIを呼び出す
// -----------------------------------------------
//
// 例:
// GET /api/Tasks?processId=2
//
const response = await fetch(
`${apiBaseUrl}/api/Tasks?processId=${processId}`,
{
method: "GET",
headers: {
// JWTをASP.NET Coreへ送信する
Authorization: `Bearer ${accessToken}`,
},
// 常に最新の作業一覧を取得する
cache: "no-store",
}
);
// -----------------------------------------------
// HTTPエラー
// -----------------------------------------------
if (!response.ok) {
throw new Error(
`作業一覧の取得に失敗しました。status=${response.status}`
);
}
// APIからページングされたレスポンスを取得する
const data =
(await response.json()) as TaskPagedResponse;
// 実際の作業一覧はitemsに入っている
return data.items;
}
工程一覧から作業一覧へ遷移
src/app/processes/page.tsxを変更する。
import Link from "next/link";
import { getProcesses } from "@/lib/process-api";
/**
* 工程一覧画面
*/
export default async function ProcessesPage() {
// ASP.NET Core APIから工程一覧を取得
const processes = await getProcesses();
return (
<main>
<h1>工程一覧</h1>
{processes.length === 0 ? (
<p>工程が登録されていません。</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>工程名</th>
<th>表示順</th>
<th>有効</th>
</tr>
</thead>
<tbody>
{processes.map((process) => (
<tr key={process.id}>
<td>
{process.id}
</td>
<td>
{/*
工程名をクリックすると、
その工程の作業一覧へ移動する。
例:
/processes/2/tasks
*/}
<Link
href={`/processes/${process.id}/tasks`}
>
{process.processName}
</Link>
</td>
<td>
{process.displayOrder}
</td>
<td>
{process.isActive
? "有効"
: "無効"}
</td>
</tr>
))}
</tbody>
</table>
)}
<p>
<Link href="/">
トップへ戻る
</Link>
</p>
</main>
);
}
動的ルートを作成
Next.jsのDynamic Routeを使う。
src/app/processes/[id]/tasks/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTasksByProcessId,
} from "@/lib/task-api";
/**
* 工程ごとの作業一覧画面
*/
export default async function ProcessTasksPage(
props: {
params: Promise<{
id: string;
}>;
}
) {
// -----------------------------------------------
// URLから工程IDを取得
// -----------------------------------------------
//
// /processes/2/tasks
//
// の場合、
//
// params.id = "2"
//
const params = await props.params;
const processId =
Number(params.id);
// -----------------------------------------------
// 不正なIDをチェック
// -----------------------------------------------
if (
!Number.isInteger(processId) ||
processId <= 0
) {
notFound();
}
// -----------------------------------------------
// 指定された工程の作業一覧を取得
// -----------------------------------------------
const tasks =
await getTasksByProcessId(processId);
return (
<main>
<h1>作業一覧</h1>
<p>
工程ID:{processId}
</p>
{/* 作業が存在しない場合 */}
{tasks.length === 0 ? (
<p>
この工程には作業が登録されていません。
</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>作業名</th>
<th>ステータス</th>
<th>予定日</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id}>
<td>
{task.id}
</td>
<td>
{task.taskName}
</td>
<td>
{task.status}
</td>
<td>
{/*
plannedDateがnullの場合は
"-" を表示する。
*/}
{task.plannedDate ?? "-"}
</td>
</tr>
))}
</tbody>
</table>
)}
<p>
<Link href="/processes">
工程一覧へ戻る
</Link>
</p>
</main>
);
}
動作確認
工程一覧の各工程をクリックすると、作業一覧に遷移することを確認。
フェーズ5-8:作業ステータスを更新する
ステータスの型を定義
src/lib/task-api.tsのTaskDtoを変更する。
status: string;
を
status: WorkTaskStatus;
に変更する。
TaskDtoの前に、
/**
* ASP.NET Core側のWorkTaskStatusに対応する。
*/
export type WorkTaskStatus =
| "NotStarted"
| "InProgress"
| "Completed";
を追加する。
更新用Server Actionを作成
src/app/processes/[id]/tasks/actions.ts
"use server";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import type {
WorkTaskStatus,
} from "@/lib/task-api";
/**
* 作業ステータスを更新するServer Action。
*/
export async function updateTaskStatus(
formData: FormData
) {
// -----------------------------------------------
// formから値を取得
// -----------------------------------------------
const taskId = Number(
formData.get("taskId")
);
const processId = Number(
formData.get("processId")
);
const taskName = String(
formData.get("taskName") ?? ""
);
const status = String(
formData.get("status") ?? ""
) as WorkTaskStatus;
const plannedDateValue = String(
formData.get("plannedDate") ?? ""
);
// 空文字の場合はnullにする
const plannedDate =
plannedDateValue === ""
? null
: plannedDateValue;
// -----------------------------------------------
// JWTを取得
// -----------------------------------------------
const cookieStore = await cookies();
const accessToken =
cookieStore.get("access_token")?.value;
if (!accessToken) {
throw new Error(
"アクセストークンがありません。"
);
}
// -----------------------------------------------
// API接続先
// -----------------------------------------------
const apiBaseUrl =
process.env.API_BASE_URL;
if (!apiBaseUrl) {
throw new Error(
"API_BASE_URLが設定されていません。"
);
}
// -----------------------------------------------
// ASP.NET Core APIで作業を更新
// -----------------------------------------------
const response = await fetch(
`${apiBaseUrl}/api/Tasks/${taskId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
// JWT認証
Authorization:
`Bearer ${accessToken}`,
},
body: JSON.stringify({
// 現在の作業名は変更しない
taskName,
// 現在所属している工程もそのまま送る
processId,
// 今回変更する項目
status,
// 予定日も現在値を維持する
plannedDate,
}),
}
);
// -----------------------------------------------
// 更新失敗
// -----------------------------------------------
if (!response.ok) {
const errorText =
await response.text();
console.error(
"作業更新APIエラー",
response.status,
errorText
);
throw new Error(
`作業の更新に失敗しました。status=${response.status}`
);
}
// -----------------------------------------------
// 作業一覧を再取得させる
// -----------------------------------------------
revalidatePath(
`/processes/${processId}/tasks`
);
}
作業一覧にステータス変更ボタンを追加src/app/processes/[id]/tasks/page.tsxを変更する。
importを追加する。
import {
updateTaskStatus,
} from "./actions";
ステータスを日本語表示する関数を追加する。
/**
* APIのステータスを日本語表示に変換する。
*/
function getStatusLabel(
status: string
) {
switch (status) {
case "NotStarted":
return "未着手";
case "InProgress":
return "作業中";
case "Completed":
return "完了";
default:
return status;
}
}
テーブルを変更する。
<table>
<thead>
<tr>
<th>ID</th>
<th>作業名</th>
<th>ステータス</th>
<th>予定日</th>
<th>ステータス変更</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id}>
<td>
{task.id}
</td>
<td>
{task.taskName}
</td>
<td>
{getStatusLabel(task.status)}
</td>
<td>
{task.plannedDate ?? "-"}
</td>
<td>
{/*
1作業につき1つのformを用意する。
押したボタンのvalueが
statusとしてServer Actionへ渡される。
*/}
<form action={updateTaskStatus}>
{/* 更新に必要な現在値 */}
<input
type="hidden"
name="taskId"
value={task.id}
/>
<input
type="hidden"
name="processId"
value={task.processId}
/>
<input
type="hidden"
name="taskName"
value={task.taskName}
/>
<input
type="hidden"
name="plannedDate"
value={task.plannedDate ?? ""}
/>
{/* 未着手 */}
<button
type="submit"
name="status"
value="NotStarted"
disabled={
task.status === "NotStarted"
}
>
未着手
</button>
{/* 作業中 */}
<button
type="submit"
name="status"
value="InProgress"
disabled={
task.status === "InProgress"
}
>
作業中
</button>
{/* 完了 */}
<button
type="submit"
name="status"
value="Completed"
disabled={
task.status === "Completed"
}
>
完了
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
動作確認
ステータス変更のステータスをクリックすると、ステータスが更新されることを確認。
フェーズ5-9:工程進捗画面のUI改善
CSS Modulesを作成
src/app/processes/[id]/tasks/tasks.module.css
page.tsxを変更
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTasksByProcessId,
type WorkTaskStatus,
} from "@/lib/task-api";
import {
getProcesses,
} from "@/lib/process-api";
import {
updateTaskStatus,
} from "./actions";
import styles from "./tasks.module.css";
/**
* ステータスを画面表示用の日本語へ変換する。
*/
function getStatusLabel(
status: WorkTaskStatus
) {
switch (status) {
case "NotStarted":
return "未着手";
case "InProgress":
return "作業中";
case "Completed":
return "完了";
}
}
/**
* ステータスに応じたCSSクラスを返す。
*/
function getStatusClass(
status: WorkTaskStatus
) {
switch (status) {
case "NotStarted":
return styles.notStarted;
case "InProgress":
return styles.inProgress;
case "Completed":
return styles.completed;
}
}
/**
* YYYY-MM-DD
*
* を
*
* YYYY/MM/DD
*
* に変換する。
*/
function formatDate(
date: string | null
) {
if (!date) {
return "-";
}
return date.replaceAll("-", "/");
}
/**
* 工程ごとの作業一覧画面。
*/
export default async function ProcessTasksPage(
props: {
params: Promise<{
id: string;
}>;
}
) {
// --------------------------------------------------
// URLから工程IDを取得
// --------------------------------------------------
const params = await props.params;
const processId =
Number(params.id);
// 工程IDとして不正な値なら404
if (
!Number.isInteger(processId) ||
processId <= 0
) {
notFound();
}
// --------------------------------------------------
// 工程と作業を取得
// --------------------------------------------------
//
// 2つのAPIは互いに依存していないため、
// Promise.allで並行して取得する。
//
const [
processes,
tasks,
] = await Promise.all([
getProcesses(),
getTasksByProcessId(processId),
]);
// URLで指定された工程を探す
const process =
processes.find(
(item) => item.id === processId
);
// 存在しない工程IDなら404
if (!process) {
notFound();
}
return (
<main className={styles.container}>
{/* =====================================
画面タイトル
===================================== */}
<header className={styles.header}>
<h1 className={styles.title}>
{process.processName}
</h1>
<p className={styles.description}>
工程ごとの作業進捗を確認・更新できます。
</p>
</header>
{/* =====================================
作業一覧
===================================== */}
{tasks.length === 0 ? (
<p>
この工程には作業が登録されていません。
</p>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>作業名</th>
<th>ステータス</th>
<th>予定日</th>
<th>ステータス変更</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id}>
{/* ID */}
<td>
{task.id}
</td>
{/* 作業名 */}
<td>
{task.taskName}
</td>
{/* 現在のステータス */}
<td>
<span
className={
`${styles.statusBadge} ${getStatusClass(task.status)}`
}
>
{getStatusLabel(task.status)}
</span>
</td>
{/* 予定日 */}
<td>
{formatDate(
task.plannedDate
)}
</td>
{/* =================================
ステータス更新
================================= */}
<td>
<form
action={updateTaskStatus}
className={styles.buttonGroup}
>
{/* 更新APIへ渡す現在値 */}
<input
type="hidden"
name="taskId"
value={task.id}
/>
<input
type="hidden"
name="processId"
value={task.processId}
/>
<input
type="hidden"
name="taskName"
value={task.taskName}
/>
<input
type="hidden"
name="plannedDate"
value={
task.plannedDate ?? ""
}
/>
{/* 未着手 */}
<button
className={
styles.statusButton
}
type="submit"
name="status"
value="NotStarted"
disabled={
task.status ===
"NotStarted"
}
>
未着手
</button>
{/* 作業中 */}
<button
className={
styles.statusButton
}
type="submit"
name="status"
value="InProgress"
disabled={
task.status ===
"InProgress"
}
>
作業中
</button>
{/* 完了 */}
<button
className={
styles.statusButton
}
type="submit"
name="status"
value="Completed"
disabled={
task.status ===
"Completed"
}
>
完了
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* =====================================
戻る
===================================== */}
<div className={styles.footer}>
<Link href="/processes">
← 工程一覧へ戻る
</Link>
</div>
</main>
);
}
フェーズ5-10:工程一覧を進捗ダッシュボード化
工程ごとに進捗が分かる画面へ変更する。
┌────────────────────────────┐
│ 組立工程 │
│ 全体 10件 │
│ 未着手 3 作業中 2 完了 5 │
│ ██████████ 50% │
└────────────────────────────┘
進捗集計処理を追加
src/lib/task-api.tsに追加する。
type TaskPagedResponse = {
の下に、
/**
* 工程ごとの作業進捗。
*/
export type TaskProgress = {
total: number;
notStarted: number;
inProgress: number;
completed: number;
};
を追加する。
ファイルの最後に次を追加する。
/**
* 指定した工程の全作業を取得して、
* ステータス別の件数を集計する。
*
* GET /api/Tasks はページングされているため、
* 必要に応じて2ページ目以降も取得する。
*/
export async function getTaskProgressByProcessId(
processId: number
): Promise<TaskProgress> {
// -----------------------------------------------
// JWT取得
// -----------------------------------------------
const cookieStore = await cookies();
const accessToken =
cookieStore.get("access_token")?.value;
if (!accessToken) {
throw new Error(
"アクセストークンがありません。"
);
}
// -----------------------------------------------
// API URL取得
// -----------------------------------------------
const apiBaseUrl =
process.env.API_BASE_URL;
if (!apiBaseUrl) {
throw new Error(
"API_BASE_URLが設定されていません。"
);
}
// 1回に取得する件数
const pageSize = 100;
/**
* 指定ページを取得する内部関数。
*/
async function fetchPage(
page: number
): Promise<TaskPagedResponse> {
const response = await fetch(
`${apiBaseUrl}/api/Tasks?processId=${processId}&page=${page}&pageSize=${pageSize}`,
{
method: "GET",
headers: {
Authorization:
`Bearer ${accessToken}`,
},
cache: "no-store",
}
);
if (!response.ok) {
throw new Error(
`作業進捗の取得に失敗しました。status=${response.status}`
);
}
return (
await response.json()
) as TaskPagedResponse;
}
// -----------------------------------------------
// まず1ページ目を取得
// -----------------------------------------------
const firstPage =
await fetchPage(1);
// 全作業をここへ格納する
const allTasks = [
...firstPage.items,
];
// -----------------------------------------------
// 2ページ目以降が存在する場合
// -----------------------------------------------
if (firstPage.totalPages > 1) {
const requests = [];
for (
let page = 2;
page <= firstPage.totalPages;
page++
) {
requests.push(
fetchPage(page)
);
}
// 複数ページを並行取得する
const remainingPages =
await Promise.all(requests);
for (
const page of remainingPages
) {
allTasks.push(
...page.items
);
}
}
// -----------------------------------------------
// ステータス別に集計
// -----------------------------------------------
const notStarted =
allTasks.filter(
(task) =>
task.status === "NotStarted"
).length;
const inProgress =
allTasks.filter(
(task) =>
task.status === "InProgress"
).length;
const completed =
allTasks.filter(
(task) =>
task.status === "Completed"
).length;
return {
total: allTasks.length,
notStarted,
inProgress,
completed,
};
}
工程一覧用CSSを作る
src/app/processes/processes.module.css
/* =========================================
画面全体
========================================= */
.container {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
}
/* =========================================
タイトル
========================================= */
.header {
margin-bottom: 24px;
}
.title {
margin: 0 0 8px;
font-size: 28px;
}
.description {
margin: 0;
color: #666;
}
/* =========================================
工程カード一覧
========================================= */
.grid {
display: grid;
/* 画面幅に応じてカード数を自動調整する */
grid-template-columns:
repeat(
auto-fit,
minmax(280px, 1fr)
);
gap: 16px;
}
/* =========================================
工程カード
========================================= */
.card {
display: block;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: white;
color: inherit;
text-decoration: none;
transition:
transform 0.15s,
box-shadow 0.15s;
}
/* カードにマウスを乗せたとき */
.card:hover {
transform: translateY(-2px);
box-shadow:
0 4px 12px
rgba(0, 0, 0, 0.1);
}
/* =========================================
工程名
========================================= */
.processName {
margin: 0 0 16px;
font-size: 20px;
}
/* =========================================
進捗件数
========================================= */
.total {
margin-bottom: 12px;
font-weight: bold;
}
.statusList {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.statusItem {
font-size: 14px;
}
/* =========================================
プログレスバー
========================================= */
.progressArea {
display: flex;
align-items: center;
gap: 12px;
}
.progressBar {
flex: 1;
height: 10px;
overflow: hidden;
border-radius: 5px;
background-color: #eee;
}
.progressValue {
height: 100%;
background-color: #4caf50;
}
.progressText {
min-width: 44px;
font-size: 14px;
font-weight: bold;
text-align: right;
}
/* =========================================
下部
========================================= */
.footer {
margin-top: 24px;
}
工程一覧をカード表示に変更
src/app/processes/page.tsxを変更する。
import Link from "next/link";
import {
getProcesses,
} from "@/lib/process-api";
import {
getTaskProgressByProcessId,
} from "@/lib/task-api";
import styles from "./processes.module.css";
/**
* 工程一覧・進捗ダッシュボード。
*/
export default async function ProcessesPage() {
// -----------------------------------------------
// 工程一覧を取得
// -----------------------------------------------
const processes =
await getProcesses();
// -----------------------------------------------
// 有効な工程だけを表示対象にする
// -----------------------------------------------
//
// isActive = false の工程は、
// 通常の工程進捗画面には表示しない。
//
const activeProcesses =
processes.filter(
(process) => process.isActive
);
// -----------------------------------------------
// 各工程の進捗を取得
// -----------------------------------------------
//
// 工程ごとの取得処理は互いに依存しないので、
// Promise.allで並行実行する。
//
const processesWithProgress =
await Promise.all(
activeProcesses.map(
async (process) => {
const progress =
await getTaskProgressByProcessId(
process.id
);
return {
...process,
progress,
};
}
)
);
return (
<main className={styles.container}>
{/* =====================================
画面上部
===================================== */}
<header className={styles.header}>
<h1 className={styles.title}>
工程進捗
</h1>
<p className={styles.description}>
各工程の作業進捗を確認できます。
</p>
</header>
{/* =====================================
工程一覧
===================================== */}
{processesWithProgress.length === 0 ? (
<p>
工程が登録されていません。
</p>
) : (
<div className={styles.grid}>
{processesWithProgress.map(
(process) => {
const {
progress,
} = process;
// ---------------------------------
// 完了率を計算
// ---------------------------------
const completionRate =
progress.total === 0
? 0
: Math.round(
(
progress.completed /
progress.total
) * 100
);
return (
<Link
key={process.id}
href={
`/processes/${process.id}/tasks`
}
className={styles.card}
>
{/* 工程名 */}
<h2
className={
styles.processName
}
>
{process.processName}
</h2>
{/* 全作業数 */}
<div
className={styles.total}
>
全作業:
{progress.total}件
</div>
{/* ステータスごとの件数 */}
<div
className={
styles.statusList
}
>
<span
className={
styles.statusItem
}
>
未着手:
{progress.notStarted}
</span>
<span
className={
styles.statusItem
}
>
作業中:
{progress.inProgress}
</span>
<span
className={
styles.statusItem
}
>
完了:
{progress.completed}
</span>
</div>
{/* =================================
完了率
================================= */}
<div
className={
styles.progressArea
}
>
<div
className={
styles.progressBar
}
>
<div
className={
styles.progressValue
}
style={{
width:
`${completionRate}%`,
}}
/>
</div>
<span
className={
styles.progressText
}
>
{completionRate}%
</span>
</div>
</Link>
);
}
)}
</div>
)}
<div className={styles.footer}>
<Link href="/">
← トップへ戻る
</Link>
</div>
</main>
);
}
フェーズ5-11:トップ画面をダッシュボード化
トップ画面用CSSを作成
src/app/dashboard.module.css
/* =========================================
画面全体
========================================= */
.container {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
}
/* =========================================
画面上部
========================================= */
.header {
margin-bottom: 32px;
}
.title {
margin: 0 0 8px;
font-size: 30px;
}
.description {
margin: 0;
color: #666;
}
/* =========================================
サマリーカード一覧
========================================= */
.summaryGrid {
display: grid;
/* 画面幅によってカード数を自動調整する */
grid-template-columns:
repeat(
auto-fit,
minmax(200px, 1fr)
);
gap: 16px;
margin-bottom: 32px;
}
/* =========================================
サマリーカード
========================================= */
.summaryCard {
padding: 24px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: white;
}
/* カードの項目名 */
.summaryLabel {
margin-bottom: 8px;
color: #666;
font-size: 14px;
}
/* カードの数値 */
.summaryValue {
font-size: 32px;
font-weight: bold;
}
/* =========================================
メニュー
========================================= */
.menu {
margin-bottom: 32px;
}
/* 工程進捗画面へのリンク */
.primaryLink {
display: inline-block;
padding: 12px 20px;
border-radius: 6px;
background-color: #333;
color: white;
text-decoration: none;
}
/* マウスを乗せたとき */
.primaryLink:hover {
opacity: 0.85;
}
/* =========================================
ログアウト
========================================= */
.logoutButton {
padding: 10px 18px;
border: 1px solid #bbb;
border-radius: 6px;
background-color: white;
cursor: pointer;
}
.logoutButton:hover {
background-color: #f5f5f5;
}
src/app/page.tsxを変更
import Link from "next/link";
import {
getProcesses,
} from "@/lib/process-api";
import {
getTaskProgressByProcessId,
} from "@/lib/task-api";
import {
logoutAction,
} from "./actions/logout";
import styles from "./dashboard.module.css";
/**
* トップダッシュボード。
*/
export default async function Home() {
// --------------------------------------------------
// 工程一覧を取得
// --------------------------------------------------
const processes =
await getProcesses();
// --------------------------------------------------
// 全工程の進捗を取得
// --------------------------------------------------
//
// 各工程は互いに依存していないため、
// Promise.allで並行して取得する。
//
const progresses =
await Promise.all(
processes.map(
(process) =>
getTaskProgressByProcessId(
process.id
)
)
);
// --------------------------------------------------
// システム全体の件数を集計
// --------------------------------------------------
const totalTasks =
progresses.reduce(
(total, progress) =>
total + progress.total,
0
);
const totalNotStarted =
progresses.reduce(
(total, progress) =>
total + progress.notStarted,
0
);
const totalInProgress =
progresses.reduce(
(total, progress) =>
total + progress.inProgress,
0
);
const totalCompleted =
progresses.reduce(
(total, progress) =>
total + progress.completed,
0
);
// --------------------------------------------------
// 全体の完了率
// --------------------------------------------------
const completionRate =
totalTasks === 0
? 0
: Math.round(
(
totalCompleted /
totalTasks
) * 100
);
return (
<main className={styles.container}>
{/* =====================================
タイトル
===================================== */}
<header className={styles.header}>
<h1 className={styles.title}>
工程進捗管理システム
</h1>
<p className={styles.description}>
工程全体の作業状況を確認できます。
</p>
</header>
{/* =====================================
全体サマリー
===================================== */}
<section
className={styles.summaryGrid}
>
{/* 工程数 */}
<div
className={styles.summaryCard}
>
<div
className={styles.summaryLabel}
>
工程数
</div>
<div
className={styles.summaryValue}
>
{processes.length}
</div>
</div>
{/* 全作業数 */}
<div
className={styles.summaryCard}
>
<div
className={styles.summaryLabel}
>
全作業数
</div>
<div
className={styles.summaryValue}
>
{totalTasks}
</div>
</div>
{/* 未着手 */}
<div
className={styles.summaryCard}
>
<div
className={styles.summaryLabel}
>
未着手
</div>
<div
className={styles.summaryValue}
>
{totalNotStarted}
</div>
</div>
{/* 作業中 */}
<div
className={styles.summaryCard}
>
<div
className={styles.summaryLabel}
>
作業中
</div>
<div
className={styles.summaryValue}
>
{totalInProgress}
</div>
</div>
{/* 完了 */}
<div
className={styles.summaryCard}
>
<div
className={styles.summaryLabel}
>
完了
</div>
<div
className={styles.summaryValue}
>
{totalCompleted}
</div>
</div>
{/* 完了率 */}
<div
className={styles.summaryCard}
>
<div
className={styles.summaryLabel}
>
完了率
</div>
<div
className={styles.summaryValue}
>
{completionRate}%
</div>
</div>
</section>
{/* =====================================
メニュー
===================================== */}
<section className={styles.menu}>
<Link
href="/processes"
className={styles.primaryLink}
>
工程進捗を見る
</Link>
</section>
{/* =====================================
ログアウト
===================================== */}
<form action={logoutAction}>
<button
type="submit"
className={styles.logoutButton}
>
ログアウト
</button>
</form>
</main>
);
}
動作確認
トップページで工程全体の作業状況が見られることを確認。