DELOGs
[管理画面フォーマット制作編 #6] マスタ管理-ロール管理(UIのみ)

管理画面フォーマット制作編 #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 / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング

1. ロールテーブル設計(UI観点)

ロール管理のUIを作るにあたり、まずは「どのカラムを画面で扱うか」「固定ロールとカスタムロールで何が違うか」を整理しておきます。今回はあくまでUI観点の設計なので、DB連携やAPI処理は後続の記事で扱います。

カラムの整理

ロールテーブルで管理する主なカラムと、固定ロール/カスタムロールでの編集可否は下記の通りです。
カラム名用途固定ロールカスタムロール
displayIdString表示用ID。UIで一意に見せる読み取り専用読み取り専用
codeStringロール識別子(ADMINなど)編集不可/削除不可編集可
displayNameString日本語表示名編集可編集可
priorityInt権限の優先度編集不可編集可
badgeColorStringバッジ色(HEX形式 #RRGGBB)編集可編集可
isActiveBoolean有効/無効フラグ編集不可編集可
canDownloadDataBooleanデータのダウンロード可否編集不可編集可
canEditDataBooleanデータの編集可否編集不可編集可
deletedAtDateTime?論理削除のタイムスタンプ削除不可削除可能

固定ロールとカスタムロールの違い

固定ロール(ADMIN / EDITOR / VIEWER)は、システムの基盤として削除不可で、主要フィールドも編集ロックします。
一方で、カスタムロールは業務に応じて追加・編集・削除が可能です。

UIでの表現

  • 一覧画面:ロールごとの codedisplayNameprioritybadgeColor を表示。固定ロールには「ロック」アイコンを添える。
  • 新規作成:カスタムロール用。全フィールド入力可能。
  • 編集画面:固定ロールは displayNamebadgeColor のみ編集可。それ以外は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バリデーション を準備します。
特に canDownloadDatacanEditData は 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側で「固定ロールは編集不可・削除不可」と判定できる
  • canDownloadDatacanEditData は、同じページを閲覧できるロールでも「データ操作の可否」を区別できる
  • displayId はユーザに見せるためのIDで、内部のUUIDとは別に扱う

入力ルールと制約

各フィールドのバリデーションルールを整理すると次の通りです。
表:ロール管理UIの入力制約
フィールド制約条件
code英大文字+数字+アンダースコア。正規表現 /^[A-Z][A-Z0-9_]*$/ に一致
displayName1〜100文字の文字列
priority整数、0〜999
badgeColor#RRGGBB 形式のHEXカラー
isActiveboolean(ON/OFF)
canDownloadDataboolean(ON/OFF)
canEditDataboolean(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 … 更新用。displayIdisSystem を追加し、既存レコードの保護を表現する
  • UIでは isSystem: true の場合に、codepriority、権限フラグは disabled にして誤入力を防止する
  • useForm の型引数には「そのフォームが扱うスキーマから infer した型」を使うのが鉄則です(create と edit で型が違う)。

補足:なぜ z.coerce.number() を使うのか?

React Hook Form(RHF)の公式ドキュメントでは、<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の挙動(一覧表示・新規作成・編集・削除)をシミュレーションしやすくなります。

モックデータの定義

まずはサンプルとして利用するロールのモックデータを用意します。固定ロール(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 … 固定ロールは displayNamebadgeColor しか更新できないよう制御
  • 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 を明確に分離。zodResolveruseForm の型が一致するため、型エラーを防げます(過去の課題を解消)。
  • 固定ロールのロックlocked = initialValues.isSystem を基点に、code / priority / isActive / canDownloadData / canEditData を一括で disabled。編集可能なのは 表示名とバッジ色のみ
  • 入力UI
    • badgeColor<input type="color"> で視覚的に選択。HEX の文字入力との相性も良い。
    • ブール値は共通 SwitchField で再利用性を確保。
  • アクセシビリティとテストaria-labeldata-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 → 現在地。
  • レイアウト統一SidebarTriggerSeparatorBreadcrumb の並びは 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 コンポーネントです。
ロール一覧では以下を確認できるようにします:
  • ロールの 表示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 として抽出できます。
  • 初期ソートは 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. データの確認・更新・削除画面

ロール一覧から遷移する個別ページは /masters/roles/[displayId] とします。
構成は SSRの page.tsx(データ取得・パンくずなどの枠組み)と、クライアントの client.tsxRoleForm を使った編集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.tsRoleCoderoles の型に委譲(既存インポートを壊さないための“受け皿”)

src/lib/users/schema.tsRoleCode の型がハードコードで定義されているので、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で扱いつつも、編集可否や削除可否を isSystem フラグで制御することで、誤操作を防ぎながら拡張性も担保しています。
また、バッジの色や表示名もモックデータに集約することで、Users UI や他画面からも一元的に利用できる形に整理しました。これにより、ロール周りの管理は「単一のソースから更新が反映される」状態になり、将来のAPI化・DB連携にスムーズにつながります。
次回は、同じ「管理画面フォーマット制作編」として サイドバーメニューをマスター管理の一つとして操作できるUI を作成します。
これまでハードコードしていたメニュー構造を、管理画面上から登録・編集できるようにし、ロールと連動して「どのユーザにどのメニューを見せるか」を調整できる基盤へつなげていきます。

参考文献

Githubリポジトリ

この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。

この記事の更新履歴
  • 2025/8/29

    Githubリポジトリのリンクを追加

  • 2025/8/26

    初回公開