![[管理画面フォーマット制作編 #6] マスタ管理-ロール管理(UIのみ)](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-role-ui%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット制作編 #6マスタ管理-ロール管理(UIのみ)
ロールテーブルを管理画面から操作するためのUIを、Next.js 15 + shadcn/ui + React Hook Form + Zodで実装
初回公開日
最終更新日
0. はじめに
これまでの管理画面では、ユーザ管理やプロフィール編集といった機能を中心に整備してきました。しかし、実際の業務システムを考えると「誰がどの操作をできるか」を決めるロール管理は欠かせません。
今回の記事では、ロールテーブルを操作する UI部分 を先行して実装します。まだサーバ連携は行わず、あくまで画面フォーマットとしての入力フォーム・一覧・編集UIを形にすることが目的です。
今回取り扱うロール管理の要件
ロールテーブルは、内部IDや表示用IDに加えて、以下の要素を持ちます。
code
:ロール識別子(例: ADMIN, EDITOR, VIEWER)displayName
:管理画面に表示される日本語名称priority
:権限の優先度を表す数値(大きいほど強いイメージ)badgeColor
:管理画面でバッジ表示する際のカラーコードisActive
:有効/無効フラグcanDownloadData
:データのダウンロード可否canEditData
:データの編集可否
さらに、3つの基本ロール(ADMIN / EDITOR / VIEWER)はシステム既定として扱い、削除やcode・priority・権限フラグの変更は不可とします。日本語名や色は変更できるため、組織ごとに柔軟に調整可能です。一方で、管理者が追加したカスタムロールは自由に編集・削除できる仕様とします。
今後の発展を見据えて
今回の記事ではUIのみですが、最終的には以下のような運用を想定しています。
priority
を利用して「どのメニューを表示するか」を制御- ページ閲覧は全ロールで可能でも、データのダウンロードや編集操作は特定ロールのみ許可といった細かな権限設定
- メニュー自体もマスター管理化し、各ロールごとに「見せる/見せない」を設定できる仕組み
これらを視野に入れつつ、まずは「ロール管理を操作できるUI」を整えることが重要です。
前提
本記事は、下記の続きとなります。
ここまで作成ファイルを変更、追加していきます。
技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
1. ロールテーブル設計(UI観点)
ロール管理のUIを作るにあたり、まずは「どのカラムを画面で扱うか」「固定ロールとカスタムロールで何が違うか」を整理しておきます。今回はあくまでUI観点の設計なので、DB連携やAPI処理は後続の記事で扱います。
カラムの整理
ロールテーブルで管理する主なカラムと、固定ロール/カスタムロールでの編集可否は下記の通りです。
カラム名 | 型 | 用途 | 固定ロール | カスタムロール |
---|---|---|---|---|
displayId | String | 表示用ID。UIで一意に見せる | 読み取り専用 | 読み取り専用 |
code | String | ロール識別子(ADMINなど) | 編集不可/削除不可 | 編集可 |
displayName | String | 日本語表示名 | 編集可 | 編集可 |
priority | Int | 権限の優先度 | 編集不可 | 編集可 |
badgeColor | String | バッジ色(HEX形式 #RRGGBB) | 編集可 | 編集可 |
isActive | Boolean | 有効/無効フラグ | 編集不可 | 編集可 |
canDownloadData | Boolean | データのダウンロード可否 | 編集不可 | 編集可 |
canEditData | Boolean | データの編集可否 | 編集不可 | 編集可 |
deletedAt | DateTime? | 論理削除のタイムスタンプ | 削除不可 | 削除可能 |
固定ロールとカスタムロールの違い
固定ロール(ADMIN / EDITOR / VIEWER)は、システムの基盤として削除不可で、主要フィールドも編集ロックします。
一方で、カスタムロールは業務に応じて追加・編集・削除が可能です。
一方で、カスタムロールは業務に応じて追加・編集・削除が可能です。
UIでの表現
- 一覧画面:ロールごとの
code
・displayName
・priority
・badgeColor
を表示。固定ロールには「ロック」アイコンを添える。 - 新規作成:カスタムロール用。全フィールド入力可能。
- 編集画面:固定ロールは
displayName
とbadgeColor
のみ編集可。それ以外はdisabled表示。カスタムは全フィールド編集可。
これにより、利用者が「固定ロールなのか/カスタムロールなのか」を一目で理解でき、誤操作を防ぎやすいUIになります。
2. ロール管理の画面仕様
ロール管理のUIは、利用者が「一覧で全体を把握しつつ、新規作成や編集を直感的に操作できる」ことを目指します。
一覧画面
項目 | 内容 |
---|---|
表示内容 | displayId , code , displayName , priority , badgeColor , isActive , canDownloadData , canEditData |
検索 | code / displayName 部分一致検索 |
フィルタ | 有効/無効、固定/カスタム、権限フラグ |
ソート | priority (昇順/降順) |
行操作 | 編集(全ロール)/削除(カスタムのみ) |
新規作成画面
- 対象はカスタムロールのみ。
- 入力項目:
code
/displayName
/priority
/badgeColor
/isActive
/canDownloadData
/canEditData
編集画面
区分 | 編集可否 |
---|---|
固定ロール | displayName , badgeColor のみ編集可。削除不可。 |
カスタムロール | 全項目編集可。削除はAlertDialogで確認後に論理削除を実行。 |
トースト通知とフィードバック
- 作成:
ロールを作成しました
- 更新:
ロールを更新しました
- 削除:
ロールを論理削除しました
こうしたフィードバックはUIの信頼性を高め、利用者に安心感を与えます。
3. 型とバリデーション(Zod)
ロール管理のUIでは、入力を正しく制御するために 型定義とZodバリデーション を準備します。
特に
特に
canDownloadData
と canEditData
は boolean フラグとして追加し、UI上でON/OFFを切り替え可能にします。型定義の整理
ロールのUIで扱う基本的な型を定義します。固定ロールかどうかを判別できるように
isSystem
フラグを追加し、さらにデータ操作系のフラグ(canDownloadData
/ canEditData
)を持たせます。src/lib/roles/schema.ts
を下記内容で新規作成します。ts
1// src/lib/roles/schema.ts
2export type Role = {
3 displayId: string; // 表示用ID(自動採番)
4 code: string; // ロール識別子(英大文字)
5 displayName: string; // 日本語表示名
6 priority: number; // 優先度
7 badgeColor: string; // バッジ色(HEX)
8 isActive: boolean; // 有効/無効
9 isSystem: boolean; // 固定ロールかどうか
10 canDownloadData: boolean; // データのダウンロード可否
11 canEditData: boolean; // データの編集可否
12 deletedAt?: Date | null; // 論理削除
13};
💡この型定義のポイント:
isSystem
を設けることで、UI側で「固定ロールは編集不可・削除不可」と判定できるcanDownloadData
とcanEditData
は、同じページを閲覧できるロールでも「データ操作の可否」を区別できるdisplayId
はユーザに見せるためのIDで、内部のUUIDとは別に扱う
入力ルールと制約
各フィールドのバリデーションルールを整理すると次の通りです。
表:ロール管理UIの入力制約
フィールド | 制約条件 |
---|---|
code | 英大文字+数字+アンダースコア。正規表現 /^[A-Z][A-Z0-9_]*$/ に一致 |
displayName | 1〜100文字の文字列 |
priority | 整数、0〜999 |
badgeColor | #RRGGBB 形式のHEXカラー |
isActive | boolean(ON/OFF) |
canDownloadData | boolean(ON/OFF) |
canEditData | boolean(ON/OFF) |
isSystem | 固定ロールなら true。編集制御用 |
💡ポイント解説:
code
はユニーク制約を持ち、固定ロールの場合は編集不可とするpriority
はメニュー表示制御に使うため、値域を 0〜999 に制限badgeColor
は HEX 値の入力を正規表現で厳格にチェック
Zodスキーマの実装
新規作成と更新で入力要件が少し異なるため、2種類のスキーマを用意します。
src/lib/roles/schema.ts
に下記を追記します。ts
1ソースコード
2// src/lib/roles/schema.ts
3import { z } from "zod";
4
5// ───ロール型定義(省略)
6
7/** 新規登録用のスキーマ */
8export const roleCreateSchema = z.object({
9 code: z
10 .string()
11 .regex(
12 /^[A-Z][A-Z0-9_]*$/,
13 "大文字英字と数字、アンダースコアのみ使用できます",
14 )
15 .min(2, "2文字以上で入力してください")
16 .max(50, "50文字以内で入力してください"),
17 displayName: z
18 .string()
19 .min(1, "表示名を入力してください")
20 .max(100, "100文字以内で入力してください"),
21 priority: z.coerce
22 .number()
23 .int("整数で入力してください")
24 .min(0, "0以上で入力してください")
25 .max(999, "999以下で入力してください"),
26 badgeColor: z
27 .string()
28 .regex(
29 /^#([0-9A-Fa-f]{6})$/,
30 "カラーコードは #RRGGBB の形式で入力してください",
31 ),
32 isActive: z.boolean(),
33 canDownloadData: z.boolean(),
34 canEditData: z.boolean(),
35});
36
37/** 更新用のスキーマ */
38export const roleUpdateSchema = roleCreateSchema.extend({
39 displayId: z.string().min(1, "表示IDの取得に失敗しました"),
40 isSystem: z.boolean(),
41});
42
43/** 追加: フォーム値の型を公開(RHF で使用) */
44export type RoleCreateValues = z.infer<typeof roleCreateSchema>;
45export type RoleUpdateValues = z.infer<typeof roleUpdateSchema>;
💡ポイント解説
roleCreateSchema
… 新規作成用。すべての入力を必須とし、形式を厳格にチェックするpriority
のところはフォームでは入力値は一旦文字列扱いになるので、それを数値に変換するようにします。このためスキーマの定義としてはcoerce.number()
を利用します。roleUpdateSchema
… 更新用。displayId
とisSystem
を追加し、既存レコードの保護を表現する- UIでは
isSystem: true
の場合に、code
やpriority
、権限フラグは disabled にして誤入力を防止する - useForm の型引数には「そのフォームが扱うスキーマから infer した型」を使うのが鉄則です(create と edit で型が違う)。
補足:なぜ z.coerce.number()
を使うのか?
React Hook Form(RHF)の公式ドキュメントでは、
一方、このプロジェクトでは Zod の
<input type="number">
の値はブラウザの仕様上いったん文字列で受け取り、送信時に数値へ変換する方法を紹介しています。一方、このプロジェクトでは Zod の
z.coerce.number()
を使い、最初から number として扱う方式 を採用しました。理由は以下のとおりです:
- Zod側で一元管理できる
入力検証と型変換を Zod に任せることで、RHF 側では「number型の値」として扱えばよくなり、型推論がシンプルになる。 - DataTableやソートでの一貫性
優先度(priority)など数値で扱いたいフィールドは、保存前から number として統一しておくと、後続処理で「文字列ソート」にならない。 - 実運用に近い
DBスキーマでは priority は number(整数)なので、フロントエンドでも同じく number で扱うほうが直感的。
もちろん、RHF公式の方法も正解のひとつですが、このプロジェクトでは「型をできるだけ早い段階でnumberに寄せる」方針を選びました。
4. モックとユーティリティ
今回の記事ではUIのみを対象とするため、実際のDB接続は行わず、モックデータと簡易的なユーティリティ関数で代替します。
これにより、UIの挙動(一覧表示・新規作成・編集・削除)をシミュレーションしやすくなります。
これにより、UIの挙動(一覧表示・新規作成・編集・削除)をシミュレーションしやすくなります。
モックデータの定義
まずはサンプルとして利用するロールのモックデータを用意します。固定ロール(ADMIN / EDITOR / VIEWER)は
isSystem: true
とし、削除不可・一部編集不可の挙動を確認できるようにしています。ts
1// src/lib/roles/mock.ts
2import type { Role } from "./schema";
3
4export const mockRoles: Role[] = [
5 {
6 displayId: "R00000001",
7 code: "ADMIN",
8 displayName: "管理者",
9 priority: 100,
10 badgeColor: "#D32F2F", // 赤
11 isActive: true,
12 isSystem: true,
13 canDownloadData: true,
14 canEditData: true,
15 },
16 {
17 displayId: "R00000002",
18 code: "EDITOR",
19 displayName: "編集者",
20 priority: 50,
21 badgeColor: "#1976D2", // 青
22 isActive: true,
23 isSystem: true,
24 canDownloadData: true,
25 canEditData: true,
26 },
27 {
28 displayId: "R00000003",
29 code: "VIEWER",
30 displayName: "閲覧者",
31 priority: 10,
32 badgeColor: "#616161", // グレー
33 isActive: true,
34 isSystem: true,
35 canDownloadData: false,
36 canEditData: false,
37 },
38 {
39 displayId: "R00000010",
40 code: "ANALYST",
41 displayName: "分析担当",
42 priority: 20,
43 badgeColor: "#388E3C", // 緑
44 isActive: true,
45 isSystem: false,
46 canDownloadData: true,
47 canEditData: false,
48 },
49];
💡ポイント解説
ADMIN
/EDITOR
/VIEWER
は固定ロールとしてisSystem: true
をセットANALYST
はカスタムロールの例。isSystem: false
なので自由に編集・削除可能badgeColor
は HEX 値をそのまま設定し、UIでプレビュー表示に利用する
ユーティリティ関数
モックデータを操作するためのユーティリティを用意します。
主な機能は以下の通りです。
主な機能は以下の通りです。
ユーティリティ関数一覧
関数名 | 役割 |
---|---|
getRoles() | 現在のロール一覧を取得 |
getRoleById(id) | 指定した displayId のロールを取得 |
nextDisplayId() | 新しい displayId を採番(R000000xx 形式) |
addRole(role) | 新しいロールをモックに追加 |
updateRole(role) | 既存ロールを更新(固定ロールは編集範囲を限定) |
deleteRole(id) | 論理削除。isSystem: true の場合は無効化し、deletedAt に時刻を設定 |
ソースコード
src/lib/roles/mock.ts
に書きを追記していきます。ts
1// src/lib/roles/mock.ts(追記)
2
3const roles = [...mockRoles];
4
5export function getRoles(): Role[] {
6 return roles.filter((r) => !r.deletedAt);
7}
8
9export function getRoleById(displayId: string): Role | undefined {
10 return roles.find((r) => r.displayId === displayId);
11}
12
13function pad(num: number, width = 8) {
14 return num.toString().padStart(width, "0");
15}
16
17export function nextDisplayId(): string {
18 const max = roles
19 .map((r) => Number(r.displayId.slice(1)))
20 .reduce((acc, n) => Math.max(acc, n), 0);
21 return `R${pad(max + 1)}`;
22}
23
24export function addRole(role: Omit<Role, "displayId">): Role {
25 const newRole: Role = { ...role, displayId: nextDisplayId() };
26 roles.push(newRole);
27 return newRole;
28}
29
30export function updateRole(updated: Role): boolean {
31 const index = roles.findIndex((r) => r.displayId === updated.displayId);
32 if (index === -1) return false;
33
34 if (roles[index].isSystem) {
35 // 固定ロールは displayName と badgeColor のみ更新可
36 roles[index].displayName = updated.displayName;
37 roles[index].badgeColor = updated.badgeColor;
38 return true;
39 }
40
41 roles[index] = updated;
42 return true;
43}
44
45export function deleteRole(displayId: string): boolean {
46 const role = getRoleById(displayId);
47 if (!role || role.isSystem) return false;
48 role.deletedAt = new Date();
49 return true;
50}
💡ポイント解説
updateRole
… 固定ロールはdisplayName
とbadgeColor
しか更新できないよう制御deleteRole
… 固定ロールは削除不可、カスタムロールのみdeletedAt
を設定して論理削除nextDisplayId
… 表示用IDを自動採番し、UI側で連番表示を実現
5. フォームコンポーネントの作成(shadcn/ui + RHF)
ここからは実際にUIを作成します。shadcn/ui のフォームコンポーネントと React Hook Form(RHF)、Zod を組み合わせて、新規作成フォーム と 編集フォーム を統合的に実装します。
ポイントは以下の通りです。
- フォームは
UserForm
と同じ方式で、mode: "create" | "edit"
によって分岐 - 固定ロールは
isSystem: true
を利用して入力欄を disabled にする - バリデーションエラーは Zod と RHF の組み合わせで表示
- 削除ボタンはカスタムロールのときのみ AlertDialog を表示
ソースコード
フォームは
src/components/roles/role-form.tsx
に集約し、新規作成・編集の両方をサポートします。tsx
1// src/components/roles/role-form.tsx
2"use client";
3
4import * as React from "react";
5import { cn } from "@/lib/utils";
6import { useForm } from "react-hook-form";
7import { zodResolver } from "@hookform/resolvers/zod";
8
9import { roleCreateSchema, roleUpdateSchema } from "@/lib/roles/schema";
10import { z } from "zod";
11
12import {
13 Form,
14 FormField,
15 FormItem,
16 FormLabel,
17 FormControl,
18 FormMessage,
19 FormDescription,
20} from "@/components/ui/form";
21import { Input } from "@/components/ui/input";
22import { Switch } from "@/components/ui/switch";
23import { Button } from "@/components/ui/button";
24import { Card, CardContent, CardFooter } from "@/components/ui/card";
25import {
26 AlertDialog,
27 AlertDialogAction,
28 AlertDialogCancel,
29 AlertDialogContent,
30 AlertDialogFooter,
31 AlertDialogHeader,
32 AlertDialogTitle,
33 AlertDialogTrigger,
34} from "@/components/ui/alert-dialog";
35
36/* =========================
37 公開インターフェース(Users の構成に揃える)
38 ========================= */
39
40// Create/Update の「入力型」と「変換後型」をそれぞれ定義
41// - z.input<typeof schema> : resolver へ入る前の型(priority は string なども許容)
42// - z.output<typeof schema> : resolver で変換・検証後の型(priority は number に確定)
43type RoleCreateInput = z.input<typeof roleCreateSchema>;
44export type RoleCreateValues = z.output<typeof roleCreateSchema>;
45
46type RoleUpdateInput = z.input<typeof roleUpdateSchema>;
47export type RoleUpdateValues = z.output<typeof roleUpdateSchema>;
48
49type BaseProps = {
50 onCancel?: () => void;
51};
52
53type CreateProps = BaseProps & {
54 mode: "create";
55 onSubmit: (values: RoleCreateValues) => void;
56 onDelete?: never;
57 initialValues?: never;
58};
59
60type EditProps = BaseProps & {
61 mode: "edit";
62 initialValues: RoleUpdateValues;
63 onSubmit: (values: RoleUpdateValues) => void;
64 onDelete?: () => void;
65};
66
67type Props = CreateProps | EditProps;
68
69export default function RoleForm(props: Props) {
70 return props.mode === "create" ? (
71 <CreateForm {...props} />
72 ) : (
73 <EditForm {...props} />
74 );
75}
76
77/* =========================
78 Create(新規)フォーム
79 ========================= */
80
81function CreateForm({ onSubmit, onCancel }: CreateProps) {
82 // useForm の 3 つのジェネリクスに
83 // <入力型, コンテキスト, 変換後型> を与える
84 const form = useForm<RoleCreateInput, undefined, RoleCreateValues>({
85 resolver: zodResolver(roleCreateSchema),
86 defaultValues: {
87 code: "",
88 displayName: "",
89 // 入力型(RoleCreateInput)的には string/number/unknown を許容するが、
90 // 初期値は素直に 0 を与えるのが実用的
91 priority: 0,
92 badgeColor: "#000000",
93 isActive: true,
94 canDownloadData: false,
95 canEditData: false,
96 },
97 mode: "onBlur",
98 });
99
100 const handleSubmit = form.handleSubmit(onSubmit);
101
102 return (
103 <Form {...form}>
104 <form onSubmit={handleSubmit} data-testid="role-form-create">
105 <Card className="w-full rounded-md">
106 <CardContent className="space-y-6 pt-1">
107 <CodeField />
108 <DisplayNameField />
109 <PriorityField />
110 <BadgeColorField />
111 <SwitchField name="isActive" label="有効 *" />
112 <SwitchField
113 name="canDownloadData"
114 label="データダウンロード可 *"
115 />
116 <SwitchField name="canEditData" label="データ編集可 *" />
117 </CardContent>
118
119 <CardFooter className="mt-4 flex gap-2">
120 <Button
121 type="button"
122 variant="outline"
123 onClick={onCancel}
124 className="cursor-pointer"
125 >
126 キャンセル
127 </Button>
128 <Button
129 type="submit"
130 disabled={form.formState.isSubmitting}
131 className="cursor-pointer"
132 data-testid="submit-create"
133 >
134 登録する
135 </Button>
136 </CardFooter>
137 </Card>
138 </form>
139 </Form>
140 );
141}
142
143/* =========================
144 Edit(編集)フォーム
145 ========================= */
146
147function EditForm({ initialValues, onSubmit, onCancel, onDelete }: EditProps) {
148 const form = useForm<RoleUpdateInput, undefined, RoleUpdateValues>({
149 resolver: zodResolver(roleUpdateSchema),
150 defaultValues: initialValues,
151 mode: "onBlur",
152 });
153
154 const handleSubmit = form.handleSubmit(onSubmit);
155 const locked = initialValues.isSystem; // 固定ロールは一部ロック
156
157 return (
158 <Form {...form}>
159 <form onSubmit={handleSubmit} data-testid="role-form-edit">
160 <Card className="w-full rounded-md">
161 <CardContent className="space-y-6 pt-1">
162 <DisplayIdField />
163 <CodeField disabled={locked} />
164 <DisplayNameField />
165 <PriorityField disabled={locked} />
166 <BadgeColorField />
167 <SwitchField name="isActive" label="有効 *" disabled={locked} />
168 <SwitchField
169 name="canDownloadData"
170 label="データダウンロード可 *"
171 disabled={locked}
172 />
173 <SwitchField
174 name="canEditData"
175 label="データ編集可 *"
176 disabled={locked}
177 />
178 {locked && (
179 <FormDescription className="text-xs">
180 このロールはシステム既定(固定)です。編集可能なのは「表示名」と「バッジ色」のみです。
181 </FormDescription>
182 )}
183 </CardContent>
184
185 <CardFooter className="mt-4 flex items-center justify-between">
186 <div className="flex gap-2">
187 <Button
188 type="button"
189 variant="outline"
190 onClick={onCancel}
191 className="cursor-pointer"
192 >
193 キャンセル
194 </Button>
195 <Button
196 type="submit"
197 disabled={form.formState.isSubmitting}
198 className="cursor-pointer"
199 data-testid="submit-update"
200 >
201 更新する
202 </Button>
203 </div>
204
205 {!locked && onDelete && (
206 <AlertDialog>
207 <AlertDialogTrigger asChild>
208 <Button
209 type="button"
210 variant="destructive"
211 className="cursor-pointer"
212 data-testid="delete-open"
213 >
214 削除する
215 </Button>
216 </AlertDialogTrigger>
217 <AlertDialogContent>
218 <AlertDialogHeader>
219 <AlertDialogTitle>
220 ロールを論理削除しますか?
221 </AlertDialogTitle>
222 </AlertDialogHeader>
223 <AlertDialogFooter>
224 <AlertDialogCancel data-testid="delete-cancel">
225 キャンセル
226 </AlertDialogCancel>
227 <AlertDialogAction
228 onClick={onDelete}
229 data-testid="delete-confirm"
230 >
231 削除する
232 </AlertDialogAction>
233 </AlertDialogFooter>
234 </AlertDialogContent>
235 </AlertDialog>
236 )}
237 </CardFooter>
238 </Card>
239 </form>
240 </Form>
241 );
242}
243
244/* =========================
245 小さなフィールド群
246 ========================= */
247
248function CodeField({ disabled }: { disabled?: boolean }) {
249 return (
250 <FormField
251 name="code"
252 render={({ field }) => (
253 <FormItem>
254 <FormLabel className="font-semibold">コード *</FormLabel>
255 <FormControl>
256 <Input
257 {...field}
258 disabled={disabled}
259 placeholder="ADMIN"
260 aria-label="コード"
261 data-testid="code"
262 className={cn(
263 disabled ? "bg-muted border-none focus-visible:ring-0" : "",
264 )}
265 />
266 </FormControl>
267 <FormMessage data-testid="code-error" />
268 </FormItem>
269 )}
270 />
271 );
272}
273
274function DisplayNameField() {
275 return (
276 <FormField
277 name="displayName"
278 render={({ field }) => (
279 <FormItem>
280 <FormLabel className="font-semibold">表示名 *</FormLabel>
281 <FormControl>
282 <Input
283 {...field}
284 placeholder="管理者"
285 aria-label="表示名"
286 data-testid="displayName"
287 />
288 </FormControl>
289 <FormMessage data-testid="displayName-error" />
290 </FormItem>
291 )}
292 />
293 );
294}
295
296function PriorityField({ disabled }: { disabled?: boolean }) {
297 return (
298 <FormField
299 name="priority"
300 render={({ field }) => (
301 <FormItem>
302 <FormLabel className="font-semibold">優先度 *</FormLabel>
303 <FormControl>
304 <Input
305 type="number"
306 inputMode="numeric"
307 min={0}
308 max={999}
309 step={1}
310 {...field}
311 // RHF の field.value は input 側の型(= string | number | unknown)になり得る。
312 // そのため、空は ""、それ以外は文字列化してから入れるとチラつきがない。
313 value={
314 field.value === undefined || field.value === null
315 ? ""
316 : String(field.value)
317 }
318 onChange={(e) => {
319 const v = e.target.value;
320 // 空はそのまま空で保持(必須エラーは Zod 側で担保)
321 field.onChange(v === "" ? "" : v);
322 }}
323 placeholder="100"
324 aria-label="優先度"
325 data-testid="priority"
326 disabled={disabled}
327 className={cn(
328 disabled ? "bg-muted border-none focus-visible:ring-0" : "",
329 )}
330 />
331 </FormControl>
332 <FormMessage data-testid="priority-error" />
333 </FormItem>
334 )}
335 />
336 );
337}
338
339function BadgeColorField() {
340 return (
341 <FormField
342 name="badgeColor"
343 render={({ field }) => (
344 <FormItem>
345 <FormLabel className="font-semibold">バッジ色 *</FormLabel>
346 <FormControl>
347 {/* 入力とプレビューを兼ねる */}
348 <Input
349 type="color"
350 {...field}
351 aria-label="バッジ色"
352 data-testid="badgeColor"
353 className="cursor-pointer"
354 />
355 </FormControl>
356 <FormMessage data-testid="badgeColor-error" />
357 </FormItem>
358 )}
359 />
360 );
361}
362
363function SwitchField<
364 TName extends "isActive" | "canDownloadData" | "canEditData",
365>({
366 name,
367 label,
368 disabled,
369}: {
370 name: TName;
371 label: string;
372 disabled?: boolean;
373}) {
374 return (
375 <FormField
376 name={name}
377 render={({ field }) => (
378 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
379 <FormLabel className="font-semibold">{label}</FormLabel>
380 <FormControl>
381 <Switch
382 name={field.name}
383 checked={Boolean(field.value)}
384 onCheckedChange={field.onChange}
385 disabled={disabled}
386 aria-label={label}
387 data-testid={name}
388 />
389 </FormControl>
390 <FormMessage data-testid={`${name}-error`} />
391 </FormItem>
392 )}
393 />
394 );
395}
396
397function DisplayIdField() {
398 return (
399 <FormField
400 name="displayId"
401 render={({ field }) => (
402 <FormItem>
403 <FormLabel className="font-semibold">表示ID</FormLabel>
404 <FormControl>
405 <Input
406 {...field}
407 disabled
408 aria-readonly="true"
409 data-testid="displayId"
410 className="bg-muted border-none focus-visible:ring-0"
411 />
412 </FormControl>
413 <FormMessage data-testid="displayId-error" />
414 </FormItem>
415 )}
416 />
417 );
418}
💡ポイント解説
- 二段フォーム構成:
RoleCreateValues
/RoleUpdateValues
を明確に分離。zodResolver
とuseForm
の型が一致するため、型エラーを防げます(過去の課題を解消)。 - 固定ロールのロック:
locked = initialValues.isSystem
を基点に、code
/priority
/isActive
/canDownloadData
/canEditData
を一括でdisabled
。編集可能なのは 表示名とバッジ色のみ。 - 入力UI:
badgeColor
は<input type="color">
で視覚的に選択。HEX の文字入力との相性も良い。- ブール値は共通
SwitchField
で再利用性を確保。
- アクセシビリティとテスト:
aria-label
とdata-testid
を付与し、E2E/UT の安定性と a11y を確保。 - 依存関係:Users と同じ shadcn/ui コンポーネント群のみ。Tailwind ユーティリティを最小限に使用。
6. 新規登録ページ(/masters/roles/new)
この章では、5章で作成した
配置は
<RoleForm />
を使って ロール新規登録ページ を実装します。配置は
/masters/roles/new
とし、管理画面の「マスタ管理 > ロール管理」から遷移します。構成は
client.tsx
(クライアント処理)と page.tsx
(サーバ処理+レイアウト)の2段構成。UIデモ段階なので保存処理はモックに任せ、成功トーストと一覧ページへの遷移を実装します。クライアントコンテナ(client.tsx)
tsx
1/* =========================
2 * src/app/(protected)/masters/roles/new/client.tsx
3 * - UIのみ:成功トースト→一覧へ。配列は触らない。
4 * ========================= */
5"use client";
6
7import { useRouter } from "next/navigation";
8import { toast } from "sonner";
9
10import RoleForm from "@/components/roles/role-form";
11import type { RoleCreateValues } from "@/lib/roles/schema";
12
13export default function NewRoleClient() {
14 const router = useRouter();
15
16 return (
17 <RoleForm
18 mode="create"
19 onSubmit={(values: RoleCreateValues) => {
20 // ★ UIのみ:配列へ push しない。成功扱いのトーストだけ表示。
21 toast.success("ロールを作成しました", {
22 description: [
23 `コード: ${values.code}`,
24 `表示名: ${values.displayName}`,
25 `優先度: ${values.priority}`,
26 `ダウンロード: ${values.canDownloadData ? "可" : "不可"}`,
27 `編集: ${values.canEditData ? "可" : "不可"}`,
28 ].join(" / "),
29 duration: 3500,
30 });
31
32 // 成功したら一覧へ戻す
33 router.push("/masters/roles");
34 }}
35 onCancel={() => history.back()}
36 />
37 );
38}
💡ポイント
RoleForm
(第5章で実装)をそのまま利用。mode="create"
でcode
や権限フラグの入力を有効化。onSubmit
内では 一切データを追加しない(UIデモのため)。送信値はトーストの説明文にだけ利用。router.push("/masters/roles")
で一覧へ復帰。実際の登録はバックエンド実装時にサーバアクションに置き換えます。
ページ作成(page.tsx)
src/app/(protected)/masters/roles/new/page.tsx
を下記の内容で新規作成します。tsx
1/* =========================
2 * src/app/(protected)/masters/roles/new/page.tsx
3 * - Users新規ページと統一したヘッダ/パンくず
4 * ========================= */
5import type { Metadata } from "next";
6import {
7 Breadcrumb,
8 BreadcrumbItem,
9 BreadcrumbLink,
10 BreadcrumbList,
11 BreadcrumbPage,
12 BreadcrumbSeparator,
13} from "@/components/ui/breadcrumb";
14import { Separator } from "@/components/ui/separator";
15import { SidebarTrigger } from "@/components/ui/sidebar";
16
17import Client from "./client";
18
19export const metadata: Metadata = {
20 title: "ロール新規登録 | 管理画面レイアウト【DELOGs】",
21 description:
22 "共通フォーム(shadcn/ui + React Hook Form + Zod)でロールを新規作成(UIのみ)",
23};
24
25export default function Page() {
26 return (
27 <>
28 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
29 <div className="flex items-center gap-2 px-4">
30 <SidebarTrigger className="-ml-1" />
31 <Separator
32 orientation="vertical"
33 className="mr-2 data-[orientation=vertical]:h-4"
34 />
35 <Breadcrumb>
36 <BreadcrumbList>
37 <BreadcrumbItem className="hidden md:block">
38 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
39 </BreadcrumbItem>
40 <BreadcrumbSeparator className="hidden md:block" />
41 <BreadcrumbItem className="hidden md:block">
42 <BreadcrumbLink href="/masters/roles">
43 ロール管理
44 </BreadcrumbLink>
45 </BreadcrumbItem>
46 <BreadcrumbSeparator className="hidden md:block" />
47 <BreadcrumbItem>
48 <BreadcrumbPage>ロール新規登録</BreadcrumbPage>
49 </BreadcrumbItem>
50 </BreadcrumbList>
51 </Breadcrumb>
52 </div>
53 </header>
54
55 <div className="max-w-xl p-4 pt-0">
56 <Client />
57 </div>
58 </>
59 );
60}
💡ポイント
- パンくず:
マスタ管理 → ロール管理 → ロール新規登録
の3段。/masters
→/masters/roles
→ 現在地。 - レイアウト統一:
SidebarTrigger
・Separator
・Breadcrumb
の並びは Users ページと同一。 - 責務分離:
page.tsx
は構造、client.tsx
は UI ロジック(トースト・遷移)に分離。フォームは 5章の<RoleForm />
を再利用。 - 次章への接続:このあと 7章で
/masters/roles
の一覧(DataTable 版)を実装予定。遷移先が自然につながります。
npm run dev
で/masters/roles/new
にアクセスすると下図のようになります。
7. 一覧ページ(shadcn/ui + DataTable)
ここでは ロール管理の一覧ページ を作成します。
実装のベースはユーザ管理の一覧ページと同じく、shadcn/ui と @tanstack/react-table を組み合わせた DataTable コンポーネントです。
実装のベースはユーザ管理の一覧ページと同じく、shadcn/ui と @tanstack/react-table を組み合わせた DataTable コンポーネントです。
ロール一覧では以下を確認できるようにします:
- ロールの 表示ID / コード / 表示名 / 優先度 / 権限フラグ / バッジのカラー / 状態
- 編集ボタンから詳細画面へ遷移
- 新規登録画面への導線
カラム設定ファイルの作成
tsx
1/* ===========================================
2 src/app/(protected)/masters/roles/columns.tsx
3 =========================================== */
4"use client";
5
6import Link from "next/link";
7import type { ColumnDef } from "@tanstack/react-table";
8import { SquarePen } from "lucide-react";
9import { Badge } from "@/components/ui/badge";
10import { Button } from "@/components/ui/button";
11import {
12 Tooltip,
13 TooltipContent,
14 TooltipTrigger,
15} from "@/components/ui/tooltip";
16import type { Role } from "@/lib/roles/schema";
17
18/** 状態フィルタ型(列の filterFn と揃える) */
19export type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE";
20
21/** ✔ / ✘ を視覚+SR両対応で出すユーティリティ */
22function CheckX({ ok, label }: { ok: boolean; label: string }) {
23 return (
24 <span
25 aria-label={`${label}: ${ok ? "可" : "不可"}`}
26 className={ok ? "text-foreground" : "text-muted-foreground"}
27 >
28 {ok ? "✔" : "✘"}
29 </span>
30 );
31}
32
33/** カラーセル(色チップ+HEX) */
34function ColorSwatch({ hex }: { hex: string }) {
35 return (
36 <div className="flex items-center gap-2">
37 <span
38 aria-hidden
39 className="inline-block size-4 rounded-sm border"
40 style={{ backgroundColor: hex }}
41 />
42 <span className="font-mono text-xs">{hex}</span>
43 </div>
44 );
45}
46
47export const columns: ColumnDef<Role>[] = [
48 {
49 id: "actions",
50 header: "操作",
51 enableResizing: false,
52 size: 40,
53 enableSorting: false,
54 cell: ({ row }) => (
55 <Tooltip>
56 <TooltipTrigger asChild>
57 <Button
58 asChild
59 size="icon"
60 variant="outline"
61 data-testid={`edit-${row.original.displayId}`}
62 className="size-8 cursor-pointer"
63 >
64 <Link href={`/masters/roles/${row.original.displayId}`}>
65 <SquarePen />
66 </Link>
67 </Button>
68 </TooltipTrigger>
69 <TooltipContent>
70 <p>参照・編集</p>
71 </TooltipContent>
72 </Tooltip>
73 ),
74 },
75 {
76 accessorKey: "displayId",
77 header: "表示ID",
78 size: 70,
79 enableResizing: false,
80 cell: ({ row }) => (
81 <span className="font-mono">{row.original.displayId}</span>
82 ),
83 },
84 {
85 accessorKey: "isSystem",
86 header: "種別",
87 size: 70,
88 enableResizing: false,
89 cell: ({ row }) =>
90 row.original.isSystem ? (
91 <Badge variant="secondary">固定</Badge>
92 ) : (
93 <Badge>カスタム</Badge>
94 ),
95 // 種別で絞りたくなったら filterFn を追加予定
96 },
97 {
98 accessorKey: "code",
99 header: "コード",
100 cell: ({ row }) => <span className="font-mono">{row.original.code}</span>,
101 },
102 { accessorKey: "displayName", header: "表示名" },
103 {
104 accessorKey: "priority",
105 header: "優先度",
106 size: 60,
107 enableResizing: false,
108 cell: ({ row }) => (
109 <span className="font-mono tabular-nums">{row.original.priority}</span>
110 ),
111 sortingFn: (a, b, id) => {
112 const av = Number(a.getValue(id));
113 const bv = Number(b.getValue(id));
114 return av === bv ? 0 : av > bv ? 1 : -1;
115 },
116 },
117 {
118 id: "canDownloadData",
119 header: "DL",
120 size: 40,
121 enableResizing: false,
122 enableSorting: false,
123 cell: ({ row }) => (
124 <CheckX ok={row.original.canDownloadData} label="データDL" />
125 ),
126 },
127 {
128 id: "canEditData",
129 header: "編集",
130 size: 40,
131 enableResizing: false,
132 enableSorting: false,
133 cell: ({ row }) => <CheckX ok={row.original.canEditData} label="編集" />,
134 },
135 {
136 accessorKey: "badgeColor",
137 header: "バッジ色",
138 enableSorting: false,
139 size: 120,
140 cell: ({ row }) => <ColorSwatch hex={row.original.badgeColor} />,
141 },
142 {
143 accessorKey: "isActive",
144 header: "状態",
145 size: 60,
146 enableResizing: false,
147 cell: ({ row }) =>
148 row.original.isActive ? (
149 <Badge data-testid="badge-active">有効</Badge>
150 ) : (
151 <Badge variant="outline" data-testid="badge-inactive">
152 無効
153 </Badge>
154 ),
155 // 状態フィルタ
156 filterFn: (row, _id, value: StatusFilter) =>
157 value === "ALL"
158 ? true
159 : value === "ACTIVE"
160 ? row.original.isActive
161 : !row.original.isActive,
162 },
163 // 検索用の hidden 列(displayId / code / displayName を結合)
164 {
165 id: "q",
166 accessorFn: (r) =>
167 `${r.displayId} ${r.code} ${r.displayName}`.toLowerCase(),
168 enableHiding: true,
169 enableSorting: false,
170 enableResizing: false,
171 size: 0,
172 header: () => null,
173 cell: () => null,
174 },
175];
💡 ポイント
- 操作列は「編集」ボタンのみ配置(削除は詳細画面で対応)。
priority
は数値のまま扱うためソートも自然(今回はソート機能はつけていませんが、データテーブルは別記事でカスタマイズ予定)。- 権限フラグは「✔ / ✘」で表示し、直感的に確認できる。
テーブルファイル作成
tsx
1/* ======================================================
2 src/app/(protected)/masters/roles/data-table.tsx
3 ====================================================== */
4"use client";
5
6import * as React from "react";
7import type { ColumnDef, SortingState } from "@tanstack/react-table";
8import {
9 flexRender,
10 getCoreRowModel,
11 getPaginationRowModel,
12 getSortedRowModel,
13 useReactTable,
14} from "@tanstack/react-table";
15import Link from "next/link";
16import { Input } from "@/components/ui/input";
17import {
18 Select,
19 SelectContent,
20 SelectItem,
21 SelectTrigger,
22 SelectValue,
23} from "@/components/ui/select";
24import {
25 Table,
26 TableBody,
27 TableCell,
28 TableHead,
29 TableHeader,
30 TableRow,
31} from "@/components/ui/table";
32import { Button } from "@/components/ui/button";
33import type { Role } from "@/lib/roles/schema";
34import type { StatusFilter } from "./columns";
35
36type Props<TData> = {
37 columns: ColumnDef<TData, unknown>[];
38 data: TData[];
39 /** 新規作成への遷移先(例:/masters/roles/new) */
40 newPath: string;
41};
42
43export default function RolesDataTable<TData extends Role>({
44 columns,
45 data,
46 newPath,
47}: Props<TData>) {
48 // 検索/フィルタUIの状態
49 const [q, setQ] = React.useState("");
50 const [status, setStatus] = React.useState<StatusFilter>("ALL");
51 const [sorting, setSorting] = React.useState<SortingState>([
52 { id: "priority", desc: true }, // 優先度の高い順で初期表示
53 ]);
54
55 // 前処理フィルタ(文字列検索・状態)
56 const filteredData = React.useMemo(() => {
57 const needle = q.trim().toLowerCase();
58 return (data as Role[]).filter((r) => {
59 const passQ =
60 !needle ||
61 `${r.displayId} ${r.code} ${r.displayName}`
62 .toLowerCase()
63 .includes(needle);
64 const passStatus =
65 status === "ALL" ||
66 (status === "ACTIVE" ? r.isActive === true : r.isActive === false);
67 return passQ && passStatus;
68 }) as unknown as TData[];
69 }, [data, q, status]);
70
71 const table = useReactTable({
72 data: filteredData,
73 columns,
74 state: { sorting },
75 onSortingChange: setSorting,
76 getCoreRowModel: getCoreRowModel(),
77 getSortedRowModel: getSortedRowModel(),
78 getPaginationRowModel: getPaginationRowModel(),
79 initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
80 });
81
82 const filteredCount = filteredData.length;
83
84 return (
85 <div className="space-y-3">
86 {/* 検索/フィルタ */}
87 <div className="flex flex-wrap items-center gap-3">
88 <Input
89 name="filter-q"
90 data-testid="filter-q"
91 value={q}
92 onChange={(e) => setQ(e.target.value)}
93 placeholder="表示ID・コード・表示名で検索"
94 className="w-[240px] basis-full text-sm md:basis-auto"
95 aria-label="検索キーワード"
96 />
97
98 <Select
99 value={status}
100 onValueChange={(v) => setStatus(v as StatusFilter)}
101 name="filter-status"
102 >
103 <SelectTrigger className="w-auto" data-testid="filter-status">
104 <SelectValue placeholder="状態" />
105 </SelectTrigger>
106 <SelectContent>
107 <SelectItem value="ALL">
108 <span className="text-muted-foreground">すべての状態</span>
109 </SelectItem>
110 <SelectItem value="ACTIVE">有効のみ</SelectItem>
111 <SelectItem value="INACTIVE">無効のみ</SelectItem>
112 </SelectContent>
113 </Select>
114 </div>
115
116 <div className="flex items-center justify-between">
117 <div className="text-sm" data-testid="count">
118 表示件数: {filteredCount} 件
119 </div>
120 <Button asChild>
121 <Link href={newPath}>新規登録</Link>
122 </Button>
123 </div>
124
125 {/* テーブル */}
126 <div className="overflow-x-auto rounded-md border pb-1">
127 <Table data-testid="roles-table" className="w-full">
128 <TableHeader className="bg-muted/50 text-xs">
129 {table.getHeaderGroups().map((hg) => (
130 <TableRow key={hg.id}>
131 {hg.headers.map((header) => (
132 <TableHead
133 key={header.id}
134 style={{ width: header.column.getSize() }}
135 >
136 {header.isPlaceholder
137 ? null
138 : flexRender(
139 header.column.columnDef.header,
140 header.getContext(),
141 )}
142 </TableHead>
143 ))}
144 </TableRow>
145 ))}
146 </TableHeader>
147 <TableBody>
148 {table.getRowModel().rows.length ? (
149 table.getRowModel().rows.map((row) => (
150 <TableRow
151 key={row.id}
152 data-testid={`row-${(row.original as Role).displayId}`}
153 >
154 {row.getVisibleCells().map((cell) => (
155 <TableCell
156 key={cell.id}
157 style={{ width: cell.column.getSize() }}
158 >
159 {flexRender(
160 cell.column.columnDef.cell,
161 cell.getContext(),
162 )}
163 </TableCell>
164 ))}
165 </TableRow>
166 ))
167 ) : (
168 <TableRow>
169 <TableCell
170 colSpan={columns.length}
171 className="text-muted-foreground py-10 text-center text-sm"
172 >
173 条件に一致するロールが見つかりませんでした。
174 </TableCell>
175 </TableRow>
176 )}
177 </TableBody>
178 </Table>
179 </div>
180
181 {/* ページング */}
182 <div className="flex items-center justify-end gap-2">
183 <span className="text-muted-foreground text-sm">
184 Page {table.getState().pagination.pageIndex + 1} /{" "}
185 {table.getPageCount() || 1}
186 </span>
187 <Button
188 variant="outline"
189 size="sm"
190 onClick={() => table.previousPage()}
191 disabled={!table.getCanPreviousPage()}
192 data-testid="page-prev"
193 className="cursor-pointer"
194 >
195 前へ
196 </Button>
197 <Button
198 variant="outline"
199 size="sm"
200 onClick={() => table.nextPage()}
201 disabled={!table.getCanNextPage()}
202 data-testid="page-next"
203 className="cursor-pointer"
204 >
205 次へ
206 </Button>
207 </div>
208 </div>
209 );
210}
💡ポイント
- 検索対象は「displayId / code / displayName」。ユーザ版の
name/email
などは含めません。 - フィルタは「状態(有効/無効)」と「システム種別(固定/カスタム)」の2軸。
- 固定ロール(ADMIN/EDITOR/VIEWER)は
isSystem=true
として抽出できます。
- 固定ロール(ADMIN/EDITOR/VIEWER)は
- 初期ソートは
priority
の降順(重要ロールが上に来る)。必要に応じてdisplayId
昇順などに変更可能です。 - ルーティング導線はロール用に合わせて
/masters/roles/new
に変更。 - コンポーネントの骨格・アクセシビリティ・テストIDの付け方はユーザ版に準拠し、プロジェクト全体で統一感を持たせています。
ページファイル作成
tsx
1/* ==================================================
2 src/app/(protected)/masters/roles/page.tsx
3 ================================================== */
4import type { Metadata } from "next";
5import { SidebarTrigger } from "@/components/ui/sidebar";
6import {
7 Breadcrumb,
8 BreadcrumbItem,
9 BreadcrumbLink,
10 BreadcrumbList,
11 BreadcrumbPage,
12 BreadcrumbSeparator,
13} from "@/components/ui/breadcrumb";
14import { Separator } from "@/components/ui/separator";
15
16import { columns } from "./columns";
17import RolesDataTable from "./data-table";
18import { getRoles } from "@/lib/roles/mock";
19
20export const metadata: Metadata = {
21 title: "ロール一覧 | 管理画面レイアウト【DELOGs】",
22 description:
23 "ロール一覧(色・種別・権限)をshadcn/ui+@tanstack/react-tableで表示",
24};
25
26export default async function Page() {
27 // UIのみ:モックから取得(論理削除は mock 側で除外)
28 const roles = getRoles();
29
30 return (
31 <>
32 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
33 <div className="flex items-center gap-2 px-4">
34 <SidebarTrigger className="-ml-1" />
35 <Separator
36 orientation="vertical"
37 className="mr-2 data-[orientation=vertical]:h-4"
38 />
39 <Breadcrumb>
40 <BreadcrumbList>
41 <BreadcrumbItem className="hidden md:block">
42 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
43 </BreadcrumbItem>
44 <BreadcrumbSeparator className="hidden md:block" />
45 <BreadcrumbItem>
46 <BreadcrumbPage>ロール一覧</BreadcrumbPage>
47 </BreadcrumbItem>
48 </BreadcrumbList>
49 </Breadcrumb>
50 </div>
51 </header>
52
53 <div className="max-w-full p-4 pt-0">
54 <RolesDataTable
55 columns={columns}
56 data={roles}
57 newPath="/masters/roles/new"
58 />
59 </div>
60 </>
61 );
62}
このページでは、ユーザ管理と同じ DataTable コンポーネントを流用しました。
モックデータ(
モックデータ(
getRoles()
)を渡すことで、即座に「ロール一覧」が表示されます。npm run dev
で/masters/roles
にアクセスすると下図のようになります。
8. データの確認・更新・削除画面
ロール一覧から遷移する個別ページは
構成は SSRの
今回は UI 記事のため、保存や削除は トースト通知を出して成功と見なす 挙動に留めます(実データ変更はしない)。固定ロール(
/masters/roles/[displayId]
とします。構成は SSRの
page.tsx
(データ取得・パンくずなどの枠組み)と、クライアントの client.tsx
(RoleForm
を使った編集UI)に分離し、Users で採用したパターンを踏襲します。今回は UI 記事のため、保存や削除は トースト通知を出して成功と見なす 挙動に留めます(実データ変更はしない)。固定ロール(
isSystem: true
)は RoleForm
側の制御で編集可能範囲が限定され、削除ボタンも出ません。クライアント側コンテナ(更新・削除のUIフロー)
tsx
1// src/app/(protected)/masters/roles/[displayId]/client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { toast } from "sonner";
6import RoleForm from "@/components/roles/role-form";
7import type { RoleUpdateValues } from "@/lib/roles/schema";
8
9type Props = {
10 initialValues: RoleUpdateValues;
11};
12
13export default function RoleEditClient({ initialValues }: Props) {
14 const router = useRouter();
15
16 return (
17 <RoleForm
18 mode="edit"
19 initialValues={initialValues}
20 onSubmit={(values: RoleUpdateValues) => {
21 // UIのみ:成功扱い(実データは変更しない)
22 toast.success("ロールを更新しました", {
23 description: `ID: ${values.displayId} / ${values.code} / 優先度: ${values.priority} / 有効: ${values.isActive ? "ON" : "OFF"} / DL: ${values.canDownloadData ? "✔" : "✘"} / 編集: ${values.canEditData ? "✔" : "✘"}`,
24 duration: 3500,
25 });
26 // 一覧へ戻す(設計上の戻り先を統一)
27 router.push("/masters/roles");
28 }}
29 onCancel={() => history.back()}
30 onDelete={() => {
31 // UIのみ:成功扱い(実データは変更しない)
32 toast.success("ロールを論理削除しました", {
33 description: `ID: ${initialValues.displayId}`,
34 duration: 3000,
35 });
36 router.push("/masters/roles");
37 }}
38 />
39 );
40}
💡ポイント
RoleForm
の props に mode="edit"
を渡して再利用しています。固定ロールは RoleForm
内で自動的に編集項目がロックされ、削除も出ません。保存・削除は モック成功 としてトースト通知のみを表示し、画面遷移で一覧へ戻します。ページファイルの作成
tsx
1// src/app/(protected)/masters/roles/[displayId]/page.tsx
2import type { Metadata } from "next";
3import { notFound } from "next/navigation";
4
5import {
6 Breadcrumb,
7 BreadcrumbItem,
8 BreadcrumbLink,
9 BreadcrumbList,
10 BreadcrumbPage,
11 BreadcrumbSeparator,
12} from "@/components/ui/breadcrumb";
13import { Separator } from "@/components/ui/separator";
14import { SidebarTrigger } from "@/components/ui/sidebar";
15
16import Client from "./client";
17import { getRoleById } from "@/lib/roles/mock";
18import type { RoleUpdateValues } from "@/lib/roles/schema";
19
20export const metadata: Metadata = {
21 title: "ロール編集 | 管理画面レイアウト【DELOGs】",
22 description: "ロールの確認・更新・削除(UIのみ・固定ロールは一部制限)",
23};
24
25export default async function Page({
26 params,
27}: {
28 params: Promise<{ displayId: string }>;
29}) {
30 const { displayId } = await params;
31
32 // モックから1件取得(存在しなければ 404)
33 const role = getRoleById(displayId);
34 if (!role) notFound();
35
36 // Role -> RoleUpdateValues(型を明示的に整える)
37 const initialValues: RoleUpdateValues = {
38 displayId: role.displayId,
39 code: role.code,
40 displayName: role.displayName,
41 priority: role.priority,
42 badgeColor: role.badgeColor,
43 isActive: role.isActive,
44 canDownloadData: role.canDownloadData,
45 canEditData: role.canEditData,
46 isSystem: role.isSystem,
47 };
48
49 return (
50 <>
51 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
52 <div className="flex items-center gap-2 px-4">
53 <SidebarTrigger className="-ml-1" />
54 <Separator
55 orientation="vertical"
56 className="mr-2 data-[orientation=vertical]:h-4"
57 />
58 <Breadcrumb>
59 <BreadcrumbList>
60 <BreadcrumbItem className="hidden md:block">
61 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
62 </BreadcrumbItem>
63 <BreadcrumbSeparator className="hidden md:block" />
64 <BreadcrumbItem className="hidden md:block">
65 <BreadcrumbLink href="/masters/roles">
66 ロール管理
67 </BreadcrumbLink>
68 </BreadcrumbItem>
69 <BreadcrumbSeparator className="hidden md:block" />
70 <BreadcrumbItem>
71 <BreadcrumbPage>ロール編集({displayId})</BreadcrumbPage>
72 </BreadcrumbItem>
73 </BreadcrumbList>
74 </Breadcrumb>
75 </div>
76 </header>
77
78 <div className="max-w-xl p-4 pt-0">
79 <Client initialValues={initialValues} />
80 </div>
81 </>
82 );
83}
💡ポイント
- ルーティングは
/masters/roles/[displayId]
。SSR のpage.tsx
でモックから対象ロールを取得し、RoleUpdateValues
に整形してclient.tsx
に渡します。 - クライアントは
RoleForm(mode="edit")
を再利用。固定ロールは表示名・バッジ色のみ編集可、削除不可という制御が自動で効きます。 - 今回は UI 記事のため、実データ変更は行わず、保存・削除時は
sonner
のトーストを出しつつ一覧に戻すだけの挙動にしています(バックエンド実装時にサーバーアクションへ置換)。
npm run dev
で/masters/roles
の参照・編集ボタンから詳細画面にアクセスすると下図のようになります。
9. マスタ一覧とメニュー表示
次に、ロール管理の導線の起点となるページ「マスタ一覧」を作成します。このページは静的に作成します。他に管理したいマスタが増えた際に追加できるようにしておきます。また、あわせてサイドバーのメニューも調整します。
マスタ一覧ページの作成
src/app/(protected)/masters/page.tsx
を下記内容で新規作成します。tsx
1/* ============================================
2 * src/app/(protected)/masters/page.tsx
3 * マスタ管理一覧(カード型で各マスターの入口を並べる)
4 * ============================================ */
5import type { Metadata } from "next";
6import Link from "next/link";
7import {
8 Breadcrumb,
9 BreadcrumbItem,
10 BreadcrumbLink,
11 BreadcrumbList,
12 BreadcrumbPage,
13 BreadcrumbSeparator,
14} from "@/components/ui/breadcrumb";
15import { Button } from "@/components/ui/button";
16import {
17 Card,
18 CardContent,
19 CardFooter,
20 CardHeader,
21 CardTitle,
22} from "@/components/ui/card";
23import { Separator } from "@/components/ui/separator";
24import { SidebarTrigger } from "@/components/ui/sidebar";
25import { Badge } from "@/components/ui/badge";
26import { cn } from "@/lib/utils"; // あれば(なければ className を直書きでもOK)
27import { ShieldCheck, Database, Tags, FolderCog } from "lucide-react";
28
29export const metadata: Metadata = {
30 title: "マスタ管理 | 管理画面レイアウト【DELOGs】",
31 description: "各種マスターテーブルの編集入口ページ",
32};
33
34type MasterCard = {
35 id: string;
36 title: string;
37 description: string;
38 href?: string; // 遷移先がある場合のみ
39 icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
40 ready: boolean; // 実装済みかどうか
41 badge?: string; // 任意のバッジ(例: New / Beta)
42};
43
44const MASTER_CARDS: MasterCard[] = [
45 {
46 id: "roles",
47 title: "ロール管理",
48 description:
49 "権限ロールの新規登録/一覧/編集・削除(UIのみ・バリデーション対応)。",
50 href: "/masters/roles",
51 icon: ShieldCheck,
52 ready: true,
53 badge: "READY",
54 },
55 // 以下は将来追加していくイメージ(手動で配列に追加)
56 {
57 id: "categories",
58 title: "カテゴリ管理",
59 description: "コンテンツの分類カテゴリを管理します(Coming soon)。",
60 icon: Tags,
61 ready: false,
62 },
63 {
64 id: "datasets",
65 title: "データセット管理",
66 description: "データの種別やラベル・単位などを管理します(Coming soon)。",
67 icon: Database,
68 ready: false,
69 },
70 {
71 id: "projects",
72 title: "プロジェクトマスタ",
73 description: "プロジェクト基本情報の定義(Coming soon)。",
74 icon: FolderCog,
75 ready: false,
76 },
77];
78
79export default function Page() {
80 return (
81 <>
82 {/* ヘッダ(既存ページと同じ構成) */}
83 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
84 <div className="flex items-center gap-2 px-4">
85 <SidebarTrigger className="-ml-1" />
86 <Separator
87 orientation="vertical"
88 className="mr-2 data-[orientation=vertical]:h-4"
89 />
90 <Breadcrumb>
91 <BreadcrumbList>
92 <BreadcrumbItem className="hidden md:block">
93 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
94 </BreadcrumbItem>
95 <BreadcrumbSeparator className="hidden md:block" />
96 <BreadcrumbItem>
97 <BreadcrumbPage>一覧</BreadcrumbPage>
98 </BreadcrumbItem>
99 </BreadcrumbList>
100 </Breadcrumb>
101 </div>
102 </header>
103
104 {/* 本文 */}
105 <div className="container p-4 pt-0">
106 <div className="mb-3">
107 <p className="text-muted-foreground text-sm">
108 管理対象のマスターテーブルを選択してください。カードは実装に合わせて手動で追加します。
109 </p>
110 </div>
111
112 {/* カードグリッド */}
113 <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
114 {MASTER_CARDS.map((card) => {
115 const Icon = card.icon;
116 const disabled = !card.ready || !card.href;
117
118 return (
119 <Card
120 key={card.id}
121 className={cn("w-full", !card.ready && "opacity-80")}
122 >
123 <CardHeader className="space-y-1">
124 <div className="flex items-center gap-2">
125 <Icon className="size-5" aria-hidden />
126 <CardTitle className="text-base">{card.title}</CardTitle>
127 {card.badge && (
128 <Badge variant="secondary" className="ml-1">
129 {card.badge}
130 </Badge>
131 )}
132 </div>
133 </CardHeader>
134
135 <CardContent>
136 <p className="text-muted-foreground text-sm">
137 {card.description}
138 </p>
139 </CardContent>
140
141 <CardFooter className="flex justify-end">
142 {card.href ? (
143 <Button
144 asChild
145 variant={disabled ? "outline" : "default"}
146 disabled={disabled}
147 className="cursor-pointer"
148 data-testid={`open-${card.id}`}
149 >
150 <Link href={card.href}>開く</Link>
151 </Button>
152 ) : (
153 <Button
154 variant="outline"
155 disabled
156 className="cursor-not-allowed"
157 >
158 準備中
159 </Button>
160 )}
161 </CardFooter>
162 </Card>
163 );
164 })}
165 </div>
166 </div>
167 </>
168 );
169}
配列を更新すれば、管理するマスタを追加できるようにしています。いくつかサンプルのマスタを記載しておきました。
サイドバーメニューの調整
サイドバーメニューの調整は、過去記事 管理画面フォーマット制作編 #4 サイドバーのメニューと参照中ページの同期 で作成した
src/lib/sidebar/menu.schema.ts
で行います。ts
1// src/lib/sidebar/menu.schema.ts(抜粋)
2
3// ── 省略 ──
4
5export const MENU: MenuTree = [
6 {
7 id: "dashboard",
8 title: "ダッシュボード",
9 href: "",
10 icon: SquareTerminal,
11 match: "prefix", // /dashboard 配下すべてを受け持つ
12 children: [
13 {
14 id: "dashboard-overview",
15 title: "全体進捗",
16 href: "/dashboard",
17 match: "exact",
18 },
19 {
20 id: "dashboard-my-project",
21 title: "Myプロジェクト",
22 href: "#",
23 match: "prefix",
24 },
25 {
26 id: "dashboard-my-task",
27 title: "Myタスク",
28 href: "#",
29 match: "prefix",
30 },
31 ],
32 },
33 {
34 id: "docs",
35 title: "ドキュメント",
36 href: "",
37 icon: BookOpen,
38 match: "prefix",
39 children: [
40 {
41 id: "docs-tutorial",
42 title: "チュートリアル",
43 href: "#",
44 match: "prefix",
45 },
46 {
47 id: "docs-changelog",
48 title: "更新履歴",
49 href: "#",
50 match: "prefix",
51 },
52 ],
53 },
54 {
55 id: "settings",
56 title: "設定",
57 href: "",
58 icon: Settings2,
59 match: "prefix",
60 children: [
61 {
62 id: "settings-projects",
63 title: "プロジェクト管理",
64 href: "#",
65 match: "prefix",
66 },
67 {
68 id: "settings-masters",
69 title: "マスタ管理",
70 href: "/masters",
71 match: "prefix",
72 children: [
73 {
74 id: "masters-list",
75 title: "マスタ一覧",
76 href: "/masters",
77 match: "exact",
78 },
79 {
80 id: "masters-roles",
81 title: "ロール管理",
82 href: "/masters/roles",
83 match: "exact",
84 },
85 ],
86 },
87 {
88 id: "settings-users",
89 title: "ユーザ管理",
90 href: "/users",
91 match: "prefix", // /users 配下を親で受ける(動的URL対応)
92 children: [
93 { id: "users-list", title: "一覧", href: "/users", match: "exact" },
94 {
95 id: "users-new",
96 title: "新規登録",
97 href: "/users/new",
98 match: "exact",
99 },
100 // /users/[displayId] は列挙しない → 親 "/users" が最長一致で勝つ
101 ],
102 },
103 ],
104 },
105];
MenuTree
配列の マスタ管理 の部分だけ上記のように変更すれば完了です。下図のような表示になります。
10. (補足)ロール情報ファイルの統合
この章は過去記事で設定したファイルの修正になります。
【対象の過去記事】
【対象のファイル】
- ロール情報ファイル
src/lib/roles/mock.ts
(本記事で作成ファイル、これに集約) - ロール情報ファイル
src/lib/roles/preset.ts
(不要になる) - ユーザ情報ファイル
src/lib/users/mock.ts
- ユーザ情報のZodスキーマ
src/lib/users/schema.ts
- ユーザ一覧のカラム情報ファイル
src/app/(protected)/users/columns.tsx
roles/mock.ts
にユーティリティを追加(型/ラベル/色の共通入口)
ts
1// src/lib/roles/mock.ts (末尾に追加)
2
3/** ──上部は省略── **/
4
5/** code からロールを取得(存在しなければ undefined) */
6export function getRoleByCode(code: string): Role | undefined {
7 return getRoles().find((r) => r.code === code);
8}
9
10/** Usersのセレクト等で使うラベル "管理者(ADMIN)" 形式 */
11export function getRoleOptions(): {
12 value: Role["code"];
13 label: string;
14}[] {
15 return getRoles()
16 .filter((r) => r.isActive) // ← アクティブのみ
17 .map((r) => ({
18 value: r.code,
19 label: `${r.displayName}(${r.code})`,
20 }));
21}
22
23/** バッジ表示向けの小ユーティリティ(label と style を返す) */
24export function getRoleBadgeProps(code: string): {
25 label: string;
26 style: React.CSSProperties;
27} {
28 const r = getRoleByCode(code);
29 return {
30 label: r?.displayName ?? code,
31 // 文字色は白固定・枠線なしでOKという要望に合わせる
32 style: {
33 backgroundColor: r?.badgeColor ?? "#666",
34 color: "#fff",
35 border: "none",
36 },
37 };
38}
39
40// すべてのロールコード一覧
41export const ROLE_CODES = mockRoles.map((r) => r.code) as [
42 Role["code"],
43 ...Role["code"][],
44];
これで “ロール名/カラー/選択肢” をどこからでも roles/mock.ts だけ見れば取れるようになります。
profile-form.tsx
から preset.ts
を排除して mock ベースへ
src/components/profile/profile-form.tsx
を下記のように修正します。diff : profile-form.tsx
1- import { getRolePreset } from "@/lib/roles/preset";
2+ import type { Role } from "@/lib/roles/schema";
3+ import { getRoleBadgeProps } from "@/lib/roles/mock";
diff : profile-form.tsx
1- export type ProfileInitial = {
2- name: string;
3- email: string; // 表示のみ
4- roleCode: "ADMIN" | "EDITOR" | "VIEWER"; // 既定3種のどれか
5- currentAvatarUrl?: string; // 既存アバターのURL(public想定)
6- };
7+ export type ProfileInitial = {
8+ name: string;
9+ email: string; // 表示のみ
10+ roleCode: Role["code"]; // mock.ts のロールコードに追随
11+ currentAvatarUrl?: string; // 既存アバターのURL(public想定)
12+ };
diff : profile-form.tsx
1- const rolePreset = getRolePreset(initial.roleCode);
2+ const badge = getRoleBadgeProps(initial.roleCode);
diff : profile-form.tsx
1- <RoleBadgeRow label={rolePreset.label} badgeClass={rolePreset.badgeClass} />
2+ <RoleBadgeRow label={badge.label} badgeStyle={badge.style} />
diff : profile-form.tsx
1- function RoleBadgeRow({
2- label,
3- badgeClass,
4- }: {
5- label: string;
6- badgeClass: string;
7- }) {
8+ function RoleBadgeRow({
9+ label,
10+ badgeStyle,
11+ }: {
12+ label: string;
13+ badgeStyle: React.CSSProperties;
14+ }) {
15 return (
16 <div className="flex w-full justify-end">
17- <Badge variant="outline" className={`w-[85px] px-2 py-1 ${badgeClass}`}>
18+ <Badge variant="outline" className="w-[85px] px-2 py-1" style={badgeStyle}>
19 {label}
20 </Badge>
21 </div>
22 );
23 }
これで
preset.ts
は不要。表示名も色も常にロール実体から引けます。users/schema.ts
の RoleCode
を roles
の型に委譲(既存インポートを壊さないための“受け皿”)
src/lib/users/schema.ts
に RoleCode
の型がハードコードで定義されているので、re-export に切り替えます(既存ファイルからの import を温存するための最小変更)。diff
1// src/lib/users/schema.ts(先頭付近を置換)
2- export const ROLE_CODES = ["ADMIN", "EDITOR", "VIEWER"] as const;
3- export type RoleCode = (typeof ROLE_CODES)[number];
4+ import type { Role } from "@/lib/roles/schema";
5+ import { ROLE_CODES } from "@/lib/roles/mock"; // ← ロール一覧を取得
6+ export type RoleCode = Role["code"]; // ← ロール実体に追随
こうしておけば、プロジェクト内の
import { RoleCode } from "@/lib/users/schema"
を触らずに済みます。users/mock.ts
のロール選択肢を mock
ベースに統一
users/mock.ts
を下記のように修正します。diff : mock.ts
1+ import { getRoleOptions } from "@/lib/roles/mock";
diff : mock.ts
1- export const mockRoleOptions: RoleOption[] = [
2- { value: "ADMIN", label: "管理者(ADMIN)" },
3- { value: "EDITOR", label: "編集者(EDITOR)" },
4- { value: "VIEWER", label: "閲覧者(VIEWER)" },
5- ];
6+ export const mockRoleOptions: RoleOption[] = getRoleOptions();
以後、Users のフォーム/一覧のロール選択肢は 常に
roles/mock.ts
の内容に同期 されます。(将来、固定ロールが増えても mockRoles を直せば OK)ユーザ一覧のラベルも mock
由来に
src/app/(protected)/users/columns.tsx
にもロールをハードコーディングが残っているので修正します。ついでにバッチのカラーもroles/mock.ts
のものを利用します。diff : columnstsx
1// 修正箇所のみ抜粋
2- import type { RoleCode } from "@/lib/users/schema";
3+ import { getRoles } from "@/lib/roles/mock";
4
5- export const roleLabel: Record<RoleCode, string> = {
6- ADMIN: "管理者",
7- EDITOR: "編集者",
8- VIEWER: "閲覧者",
9- };
10+ /** ロールの表示名と色を mock から集約 */
11+ const roleInfoMap: Record<
12+ string,
13+ { label: string; color: string }
14+ > = Object.fromEntries(
15+ getRoles().map((r) => [r.code, { label: r.displayName, color: r.badgeColor }]),
16+ );
17
18- export type RoleFilter = "ALL" | RoleCode;
19+ export type RoleFilter = "ALL" | string; // コードを動的取得するため string に緩める
20
21{
22 accessorKey: "roleCode",
23 header: "ロール",
24 enableResizing: false,
25 size: 56,
26- cell: ({ row }) => (
27- <Badge variant="secondary">{roleLabel[row.original.roleCode]}</Badge>
28- ),
29+ cell: ({ row }) => {
30+ const info = roleInfoMap[row.original.roleCode];
31+ const label = info?.label ?? row.original.roleCode;
32+ const style = info
33+ ? { backgroundColor: info.color, color: "#fff", border: "none" }
34+ : undefined;
35+ return (
36+ <Badge variant="secondary" style={style} title={row.original.roleCode}>
37+ {label}
38+ </Badge>
39+ );
40+ },
41 // ロールフィルタ("ALL"なら素通し)
42 filterFn: (row, _id, value: RoleFilter) =>
43 value === "ALL" ? true : row.original.roleCode === value,
44 },
これで ADMIN/EDITOR/VIEWER/ANALYST など、mock にある すべてのロール が自動でラベル&色に反映されます。
11. まとめと次回予告
今回の記事では、ロール管理のUIをテーマに 新規作成・一覧表示・詳細編集・削除フロー を shadcn/ui と React Hook Form、Zod を組み合わせて構築しました。
固定ロール(ADMIN/EDITOR/VIEWER)とカスタムロールを同じUIで扱いつつも、編集可否や削除可否を
固定ロール(ADMIN/EDITOR/VIEWER)とカスタムロールを同じUIで扱いつつも、編集可否や削除可否を
isSystem
フラグで制御することで、誤操作を防ぎながら拡張性も担保しています。また、バッジの色や表示名もモックデータに集約することで、Users UI や他画面からも一元的に利用できる形に整理しました。これにより、ロール周りの管理は「単一のソースから更新が反映される」状態になり、将来のAPI化・DB連携にスムーズにつながります。
次回は、同じ「管理画面フォーマット制作編」として サイドバーメニューをマスター管理の一つとして操作できるUI を作成します。
これまでハードコードしていたメニュー構造を、管理画面上から登録・編集できるようにし、ロールと連動して「どのユーザにどのメニューを見せるか」を調整できる基盤へつなげていきます。
これまでハードコードしていたメニュー構造を、管理画面上から登録・編集できるようにし、ロールと連動して「どのユーザにどのメニューを見せるか」を調整できる基盤へつなげていきます。
参考文献
- React Hook Form – API Reference
- Zod – TypeScript-first schema validation
- shadcn/ui Documentation
- TanStack Table (React Table) Docs
- Next.js App Router Documentation
Githubリポジトリ
この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
この記事の更新履歴
2025/8/29
Githubリポジトリのリンクを追加
2025/8/26
初回公開
▼ 関連記事
[管理画面フォーマット制作編 #9] Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法
これまで進めてきたログイン画面、ユーザー管理、ロール管理、サイドバー管理などをまとめ、「UIのみ版」デモを公開
2025/9/4公開
![[管理画面フォーマット制作編 #9] Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fdashboard-format-ui-demo%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UI
管理画面に「ログイン後の404ページ」と、ログイン前にユーザが管理者へ依頼できる「パスワード忘れ導線UI」を追加
2025/9/2公開
![[管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UIのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-404-password-forgot%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #7] サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで
サイドバーに表示するメニューをUIから登録・編集・削除できる管理画面を作成
2025/8/29公開
![[管理画面フォーマット制作編 #7] サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御までのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-menu-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更
管理画面に「プロフィール」ページを追加し、ユーザ自身が情報やパスワードを更新できるUIを作成
2025/8/22公開
![[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-profile-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期
Next.js App Router + shadcn/ui のサイドバーで「いま見ているページ」を正しくハイライト
2025/8/19公開
![[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fsidebar-active-sync%2Fhero-thumbnail.jpg&w=1200&q=75)