DELOGs
[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装

管理画面フォーマット開発編 #8 後編
部署別ロール ─ 管理UIとServer Action実装

部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築

初回公開日

最終更新日

0. はじめに(後編の狙い)

【管理画面フォーマット開発編 #8 前編】 部署別ロール ─ DepartmentRoleテーブル導入とDB設計 では DepartmentRoleテーブルの設計・Prismaモデル更新・マイグレーション を中心に基盤を整備しました。
後編では、これを実際の管理画面から操作できるように Server Action と管理UI を構築していきます。

本編の位置づけ

本記事の位置づけを、前編との関係も含めて整理すると次の通りです。
区分前編(DB/Prisma)後編(本記事:UI/SA)
スキーマDepartmentRoleテーブルの導入、UserへのXOR制約Prisma Clientを利用して実際のデータを操作
制約priority制約、override/customのXOR、Userの整合性UI/Server Actionで入力検証を二重に担保
実効ロールeffective-role.ts による合成実装getUserSnapshot 内部に反映済み、UI/ガードは変更不要
管理機能-新規登録・更新・一覧画面の実装

本編の狙い

後編で実現するのは「DBに存在するDepartmentRoleを、UIから安全に操作できる状態にすること」です。
単にCRUDを作るだけではなく、以下のような観点を盛り込みます。
観点説明
安全性DB制約に加え、Server ActionとUIフォームの二重バリデーションで担保する
検証性新規 → 更新 → 一覧の順で作ることで、テストデータを活用しやすくする
一貫性既存のRole管理UIと同じフォーム構造・UXを踏襲する
RBACとの整合性guardHrefOrRedirectやmenu.rbacの既存仕組みはそのまま利用する

記事の進め方

本記事では、以下の順序で解説を進めます。
  1. 新規登録:override/customを選択可能なフォームとServer Actionを実装
  2. 更新:詳細画面を開き、条件に応じて編集範囲を切り替え
  3. 一覧:DepartmentRoleを検索・ソート可能に表示
それぞれの章で、Server Action → UI → 動作確認の流れを繰り返すことで、最終的に運用可能な管理UIを完成させます。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
本記事では、前回の記事 【管理画面フォーマット開発編 #8 前編】部署別ロール ─ DepartmentRoleテーブル導入とDB設計 までのソースコードを引き継いで追加・編集していきます。

1. 新規登録(DepartmentRole.create)

この章では、テナント側(部署)のオペレータが 独自ロール(DepartmentRole の custom モード)を新規作成 できるようにします。
前編で実装した DB 制約(priority ≤ 99、override/custom の XOR)を前提に、Zod → Server Action → UI フォーム → 画面の順に組み立てます。
補足:
新規登録はテナント独自ロール(custom)専用です。
既存のグローバル Role の「名称/色」だけを部署で上書きしたい場合(override)は 編集章 で扱います。
txt
1# 1章のアウトライン(文字図) 2 3[要件整理] 4 └─ 新規 = DepartmentRole.custom のみ 5 ・code/name/priority/can* 必須 6 ・priority ≤ 99 7 ・departmentId はログイン中ユーザの所属から自動付与 8 9[Zod スキーマ] 10 └─ src/lib/department-roles/schema.ts 11 ・create 用の単一スキーマ(custom 固定) 12 ・共通フィールドは一意に定義 13 14[Server Action] 15 └─ src/app/(protected)/masters/roles/_actions/department-roles/create.ts 16 入力検証 → 部署解決 → Prisma.create → displayId を返す 17 ※ 重複 code / priority 違反 / その他 DB エラーの分岐 18 19[UI フォーム] 20 └─ src/components/department-roles/department-role-form.tsx 21 RHF + zodResolver / CreateForm だけ 22 code/name/priority/badgeColor/isEnabled/can* を入力 23 24[ページ] 25 ├─ src/app/(protected)/masters/roles/new/page.tsx(SSR ガード+枠) 26 └─ src/app/(protected)/masters/roles/new/client.tsx(SA 呼び出し+トースト)

要件整理(custom 固定)

新規登録で扱うのは DepartmentRole の custom モードに限定します。部署ごとの独自ロールを作るユースケースに絞り、override(見た目上書き)は編集でカバーします。
項目仕様(custom)
作成対象DepartmentRole(custom モード)
必須フィールドcode(部署内一意), name, priority(≤ 99), canEditData, canDownloadData
任意フィールドbadgeColor, remarks, isEnabled(初期 true)
禁止/自動付与roleId は常に NULLdepartmentIdログイン中ユーザの所属部署を自動付与
制約(DB 側)(departmentId, code) 一意 / priority ≤ 99 / custom と override の XOR
エラーハンドリングcode 重複 / priority 違反 / その他 DB 例外
以降では、この要件を Zod / Server Action / UI の順に落とし込みます。

Zod スキーマと型(custom 用の単一スキーマ)

フィールドごとに 一意の定義 を作成し、フォーム・Server Action 双方で同じ定義を使い回します。
ここでは新規(custom 固定)だけを扱うため、 単一スキーマ で十分です(override は編集章で別途扱う)。
ts
1// src/lib/department-roles/schema.ts 2import { z } from "zod"; 3 4/** 入力ルール(数字は定数化すると見直しやすい) */ 5export const DR_CODE_MAX = 50 as const; 6export const DR_NAME_MAX = 100 as const; 7export const DR_PRIORITY_MAX = 99 as const; 8export const DR_REMARKS_MAX = 255 as const; 9 10/** 共通フィールド定義(単一の定義に集約) */ 11const codeSchema = z 12 .string() 13 .regex( 14 /^[A-Z][A-Z0-9_]*$/, 15 "大文字英字・数字・アンダースコアのみ使用できます", 16 ) 17 .min(2, "2文字以上で入力してください") 18 .max(DR_CODE_MAX, `${DR_CODE_MAX}文字以内で入力してください`); 19 20const nameSchema = z 21 .string() 22 .min(1, "表示名を入力してください") 23 .max(DR_NAME_MAX, `${DR_NAME_MAX}文字以内で入力してください`); 24 25const prioritySchema = z.coerce 26 .number() 27 .int("整数で入力してください") 28 .min(0, "0以上で入力してください") 29 .max(DR_PRIORITY_MAX, `${DR_PRIORITY_MAX}以下で入力してください`); 30 31const badgeColorSchema = z 32 .string() 33 .regex( 34 /^#([0-9A-Fa-f]{6})$/, 35 "カラーコードは #RRGGBB の形式で入力してください", 36 ) 37 .optional(); 38 39const remarksSchema = z 40 .string() 41 .max(DR_REMARKS_MAX, `${DR_REMARKS_MAX}文字以内で入力してください`) 42 .optional(); 43 44/** ── 新規(custom 固定): roleId は扱わない ── */ 45export const departmentRoleCreateSchema = z.object({ 46 code: codeSchema, 47 name: nameSchema, 48 priority: prioritySchema, // DB 側でも ≤99 を保証 49 badgeColor: badgeColorSchema, 50 isEnabled: z.boolean().default(true), 51 canDownloadData: z.boolean(), 52 canEditData: z.boolean(), 53 remarks: remarksSchema, 54}); 55 56/** Zod から型を派生(唯一の真実) */ 57export type DepartmentRoleCreateValues = z.infer< 58 typeof departmentRoleCreateSchema 59>; 60 61/** 追加:RHF の「入力型」(coerce の前提で priority は unknown を許容) */ 62export type DepartmentRoleCreateInput = z.input< 63 typeof departmentRoleCreateSchema 64>;
  • priorityz.coerce.number() を使い、フォームからの文字列入力にも自然に対応します。
  • DB の priority ≤ 99二重でガード(UI/SA と DB)。
  • roleId は custom では不要のためスキーマに含めません(=常に NULL)。

Server Action:create(部署解決 → 作成 → displayId 返却)

ここでは、共通仕様に合わせて lookupSessionFromCookie で認証 → getUserSnapshot で権限確認 → 所属部署解決 → Zod 検証 → DB 登録 の順に実装します。
ts
1// src/app/_actions/department-roles/create.ts 2"use server"; 3 4import "server-only"; 5import { prisma } from "@/lib/database"; 6import { lookupSessionFromCookie } from "@/lib/auth/session"; 7import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 8import { 9 departmentRoleCreateSchema, 10 type DepartmentRoleCreateValues, 11} from "@/lib/department-roles/schema"; 12 13/** 共通仕様に合わせた返却型(将来は共通モジュールへ移動して import 推奨) */ 14type ActionResult = { ok: true } | { ok: false; message: string }; 15 16/** 部署ロール(custom)新規作成 */ 17export async function createDepartmentRole( 18 values: DepartmentRoleCreateValues, 19): Promise<ActionResult> { 20 // 1) 認証 21 const ses = await lookupSessionFromCookie(); 22 if (!ses.ok) return { ok: false, message: "認証が必要です。" }; 23 24 // 2) 権限(書き込み権限が必要) 25 const snap = await getUserSnapshot(ses.userId); 26 if (!snap || !snap.canEditData) { 27 return { ok: false, message: "この操作を行う権限がありません。" }; 28 } 29 30 // 3) 所属部署の解決 31 const me = await prisma.user.findUnique({ 32 where: { id: ses.userId }, 33 select: { departmentId: true }, 34 }); 35 if (!me?.departmentId) { 36 return { ok: false, message: "部署情報を取得できませんでした。" }; 37 } 38 39 // 4) サーバ側の最終入力検証(詳細なフィールドエラーは UI に委譲) 40 const parsed = departmentRoleCreateSchema.safeParse(values); 41 if (!parsed.success) { 42 return { ok: false, message: "入力内容を確認してください。" }; 43 } 44 const { 45 code, 46 name, 47 priority, 48 badgeColor, 49 isEnabled, 50 canDownloadData, 51 canEditData, 52 remarks, 53 } = parsed.data; 54 55 // 5) 部署内コード重複チェック(@@unique([departmentId, code]) の前に早期弾く) 56 const exists = await prisma.departmentRole.findFirst({ 57 where: { departmentId: me.departmentId, code }, 58 select: { id: true }, 59 }); 60 if (exists) { 61 return { ok: false, message: "このコードは既に使用されています。" }; 62 } 63 64 // 6) 登録(custom 固定: roleId は常に NULL) 65 try { 66 await prisma.departmentRole.create({ 67 data: { 68 departmentId: me.departmentId, 69 code, 70 name, 71 priority, 72 badgeColor: badgeColor ?? null, 73 isEnabled, 74 canDownloadData, 75 canEditData, 76 isSystem: false, // custom 作成は部署ローカルなので system 扱いしない 77 remarks: remarks ?? null, 78 }, 79 select: { id: true }, // 返却しないが SELECT は最小限に 80 }); 81 } catch (e) { 82 // ユニーク制約 or CHECK 失敗などの一般化したエラーメッセージ 83 console.error("[createDepartmentRole] DB create failed:", e); 84 return { ok: false, message: "ロールの登録に失敗しました。" }; 85 } 86 87 // 7) 完了 88 return { ok: true }; 89}
  • 認証/権限/部署解決は既存の共通ユーティリティに準拠。
  • Zod での最終検証(priority ≤ 99 等)は DB 制約と二重化 して安全性を担保。
  • 返却は共通の ActionResult で統一し、詳細エラーは UI の入力エラー表示(Zod)側を基本とする方針に合わせています。
  • ここで roleId は一切扱わず custom 固定としています(override は「編集」で対応)。

UI:新規フォーム(custom 固定)

フォームは既存の Users と同じパターンに合わせ、 CreateForm 単体 で構成します。
RHF + zodResolver、小さなフィールド群は 同一ファイル に定義し、一意の Zod 定義に準拠します。
tsx
1// src/components/department-roles/department-role-form.tsx 2"use client"; 3 4import * as React from "react"; 5import { useForm } from "react-hook-form"; 6import { zodResolver } from "@hookform/resolvers/zod"; 7 8import { 9 departmentRoleCreateSchema, 10 type DepartmentRoleCreateInput, 11 type DepartmentRoleCreateValues, 12 DR_PRIORITY_MAX, 13} from "@/lib/department-roles/schema"; 14 15import { 16 Form, 17 FormField, 18 FormItem, 19 FormLabel, 20 FormControl, 21 FormMessage, 22 FormDescription, 23} from "@/components/ui/form"; 24import { Input } from "@/components/ui/input"; 25import { Switch } from "@/components/ui/switch"; 26import { Button } from "@/components/ui/button"; 27import { Card, CardContent, CardFooter } from "@/components/ui/card"; 28 29/* ========================= 30 公開インターフェース(型) 31 ========================= */ 32 33type BaseProps = { 34 onCancel?: () => void; 35}; 36 37type CreateProps = BaseProps & { 38 mode: "create"; 39 onSubmit: (values: DepartmentRoleCreateValues) => void; 40}; 41 42type Props = CreateProps; // まずは新規専用。編集は #2 章で追加予定 43 44/* ========================= 45 エクスポート本体 46 ========================= */ 47 48export default function DepartmentRoleForm(props: Props) { 49 // 将来 edit 追加時も Users と同様に分岐させる 50 return <CreateForm {...props} />; 51} 52 53/* ========================= 54 Create(新規)フォーム 55 ========================= */ 56 57function CreateForm({ onSubmit, onCancel }: CreateProps) { 58 // ▼ Users と同じ構成。priority が coerce なので 3 ジェネリクスで厳密化 59 const form = useForm< 60 DepartmentRoleCreateInput, 61 undefined, 62 DepartmentRoleCreateValues 63 >({ 64 resolver: zodResolver(departmentRoleCreateSchema), 65 defaultValues: { 66 code: "", 67 name: "", 68 priority: 0, 69 badgeColor: "#666666", 70 isEnabled: true, 71 canDownloadData: false, 72 canEditData: false, 73 remarks: "", 74 }, 75 mode: "onBlur", 76 }); 77 78 const handleSubmit = form.handleSubmit(onSubmit); 79 80 return ( 81 <Form {...form}> 82 <form data-testid="department-role-form-create" onSubmit={handleSubmit}> 83 <Card className="w-full rounded-md"> 84 <CardContent className="space-y-6 pt-1"> 85 <CodeField /> 86 <NameField /> 87 <PriorityField /> 88 <BadgeColorField /> 89 <IsEnabledField /> 90 <CanDownloadField /> 91 <CanEditField /> 92 <RemarksField /> 93 </CardContent> 94 95 <CardFooter className="mt-4 flex gap-2"> 96 <Button 97 type="button" 98 variant="outline" 99 onClick={onCancel} 100 data-testid="cancel-btn" 101 className="cursor-pointer" 102 > 103 キャンセル 104 </Button> 105 <Button 106 type="submit" 107 data-testid="submit-create" 108 disabled={form.formState.isSubmitting} 109 className="cursor-pointer" 110 > 111 登録する 112 </Button> 113 </CardFooter> 114 </Card> 115 </form> 116 </Form> 117 ); 118} 119 120/* ========================= 121 小さなフィールド群(同ファイル内) 122 - Users と同じ分割方針 123 - FormField は RHF context から control を取得 124 ========================= */ 125 126// コード 127function CodeField() { 128 return ( 129 <FormField 130 name="code" 131 render={({ field }) => ( 132 <FormItem> 133 <FormLabel className="font-semibold">コード&nbsp;*</FormLabel> 134 <FormControl> 135 <Input 136 {...field} 137 placeholder="ANALYST" 138 aria-label="コード" 139 autoComplete="off" 140 data-testid="code" 141 /> 142 </FormControl> 143 <FormMessage data-testid="code-error" /> 144 </FormItem> 145 )} 146 /> 147 ); 148} 149 150// 表示名 151function NameField() { 152 return ( 153 <FormField 154 name="name" 155 render={({ field }) => ( 156 <FormItem> 157 <FormLabel className="font-semibold">表示名&nbsp;*</FormLabel> 158 <FormControl> 159 <Input 160 {...field} 161 placeholder="分析担当" 162 aria-label="表示名" 163 autoComplete="off" 164 data-testid="name" 165 /> 166 </FormControl> 167 <FormMessage data-testid="name-error" /> 168 </FormItem> 169 )} 170 /> 171 ); 172} 173 174// 優先度(coerce 対応:入力中は "" を許容 → Zod で数値に確定) 175function PriorityField() { 176 return ( 177 <FormField 178 name="priority" 179 render={({ field }) => ( 180 <FormItem> 181 <FormLabel className="font-semibold">優先度&nbsp;*</FormLabel> 182 <FormControl> 183 <Input 184 type="number" 185 inputMode="numeric" 186 min={0} 187 max={DR_PRIORITY_MAX} 188 step={1} 189 {...field} 190 value={ 191 field.value === undefined || field.value === null 192 ? "" 193 : String(field.value) 194 } 195 onChange={(e) => { 196 const v = e.target.value; 197 // 空文字は許容(Zod が最終確定) 198 field.onChange(v === "" ? "" : v); 199 }} 200 placeholder="20" 201 aria-label="優先度" 202 data-testid="priority" 203 /> 204 </FormControl> 205 <FormDescription className="text-xs"> 206 0〜{DR_PRIORITY_MAX}{" "} 207 の整数で入力してください(99以下で部署ロール)。 208 </FormDescription> 209 <FormMessage data-testid="priority-error" /> 210 </FormItem> 211 )} 212 /> 213 ); 214} 215 216// バッジ色 217function BadgeColorField() { 218 return ( 219 <FormField 220 name="badgeColor" 221 render={({ field }) => ( 222 <FormItem> 223 <FormLabel className="font-semibold">バッジ色&nbsp;*</FormLabel> 224 <FormControl> 225 <Input 226 type="color" 227 {...field} 228 aria-label="バッジ色" 229 data-testid="badgeColor" 230 className="cursor-pointer" 231 /> 232 </FormControl> 233 <FormMessage data-testid="badgeColor-error" /> 234 </FormItem> 235 )} 236 /> 237 ); 238} 239 240// 有効フラグ 241function IsEnabledField() { 242 return ( 243 <FormField 244 name="isEnabled" 245 render={({ field }) => ( 246 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 247 <div className="space-y-0.5"> 248 <FormLabel className="font-semibold">有効&nbsp;*</FormLabel> 249 <FormDescription>ONで有効/OFFで無効</FormDescription> 250 </div> 251 <FormControl> 252 <Switch 253 name={field.name} 254 checked={Boolean(field.value)} 255 onCheckedChange={field.onChange} 256 aria-label="有効" 257 data-testid="isEnabled" 258 /> 259 </FormControl> 260 <FormMessage data-testid="isEnabled-error" /> 261 </FormItem> 262 )} 263 /> 264 ); 265} 266 267// データDL可 268function CanDownloadField() { 269 return ( 270 <FormField 271 name="canDownloadData" 272 render={({ field }) => ( 273 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 274 <FormLabel className="font-semibold">データDL可&nbsp;*</FormLabel> 275 <FormControl> 276 <Switch 277 name={field.name} 278 checked={Boolean(field.value)} 279 onCheckedChange={field.onChange} 280 aria-label="データDL可" 281 data-testid="canDownloadData" 282 /> 283 </FormControl> 284 <FormMessage data-testid="canDownloadData-error" /> 285 </FormItem> 286 )} 287 /> 288 ); 289} 290 291// データ編集可 292function CanEditField() { 293 return ( 294 <FormField 295 name="canEditData" 296 render={({ field }) => ( 297 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 298 <FormLabel className="font-semibold">データ編集可&nbsp;*</FormLabel> 299 <FormControl> 300 <Switch 301 name={field.name} 302 checked={Boolean(field.value)} 303 onCheckedChange={field.onChange} 304 aria-label="データ編集可" 305 data-testid="canEditData" 306 /> 307 </FormControl> 308 <FormMessage data-testid="canEditData-error" /> 309 </FormItem> 310 )} 311 /> 312 ); 313} 314 315// 備考 316function RemarksField() { 317 return ( 318 <FormField 319 name="remarks" 320 render={({ field }) => ( 321 <FormItem> 322 <FormLabel className="font-semibold">備考</FormLabel> 323 <FormControl> 324 <Input 325 {...field} 326 placeholder="メモなど" 327 aria-label="備考" 328 autoComplete="off" 329 data-testid="remarks" 330 /> 331 </FormControl> 332 <FormMessage data-testid="remarks-error" /> 333 </FormItem> 334 )} 335 /> 336 ); 337}
  • useForm<入力型, undefined, 変換後型> の 3 つ目の型引数は handleSubmit に渡る値の型
    ここを DepartmentRoleCreateValues にすることで、onSubmit では priority: number に確定した値を安全に扱えます。
  • priority フィールドは Users でやっているのと同じく、空文字を許容 → Zod 側で必須/数値化 の流れにしています(入力中のチラつき防止)。

画面:/masters/roles/new(SSR ガード+SA 呼び出し)

この節では、部署ロール(DepartmentRole/custom)を新規作成する画面/masters/roles/new に実装します。SSR でのアクセスガード(guardHrefOrRedirect)を通し、クライアント側で Server Action createDepartmentRole を呼び出して登録します。フォームは前節で作った DepartmentRoleForm をそのまま用い、成功時は一覧 /masters/roles に戻る一貫した UX にします。
要素ポイント
ルート/masters/roles/new(テナント視点の「ロール」は Role/DepartmentRole の混在で一本化)
ガードSSR で guardHrefOrRedirect("/masters/roles/new", "/")
フォームDepartmentRoleForm(Users と同じ構成規約・any 不使用)
登録処理createDepartmentRole(values) を呼び、成功で Toast → 一覧へ遷移
tsx
1// src/app/(protected)/masters/roles/new/page.tsx 2import type { Metadata } from "next"; 3import { redirect } from "next/navigation"; 4import { 5 Breadcrumb, 6 BreadcrumbItem, 7 BreadcrumbLink, 8 BreadcrumbList, 9 BreadcrumbPage, 10 BreadcrumbSeparator, 11} from "@/components/ui/breadcrumb"; 12import { Separator } from "@/components/ui/separator"; 13import { SidebarTrigger } from "@/components/ui/sidebar"; 14import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 15import Client from "./client"; 16 17export const metadata: Metadata = { 18 title: "ロール新規登録(部署ローカル)", 19 description: 20 "部署ロール(DepartmentRole/custom)を新規作成。shadcn/ui + RHF + Zod + Server Action", 21}; 22 23export default async function Page() { 24 // ★ SSR ガードで viewer を取得 25 const viewer = await guardHrefOrRedirect("/masters/roles/new", "/"); 26 27 // ★ 編集権限がなければ強制リダイレクト 28 if (!viewer.canEditData) { 29 // guardHrefOrRedirect は内部で redirect() を呼べるので 30 // 明示的に使うなら: 31 return redirect("/"); 32 } 33 return ( 34 <> 35 <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"> 36 <div className="flex items-center gap-2 px-4"> 37 <SidebarTrigger className="-ml-1" /> 38 <Separator 39 orientation="vertical" 40 className="mr-2 data-[orientation=vertical]:h-4" 41 /> 42 <Breadcrumb> 43 <BreadcrumbList> 44 <BreadcrumbItem className="hidden md:block"> 45 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 46 </BreadcrumbItem> 47 <BreadcrumbSeparator className="hidden md:block" /> 48 <BreadcrumbItem className="hidden md:block"> 49 <BreadcrumbLink href="/masters/roles"> 50 ロール管理 51 </BreadcrumbLink> 52 </BreadcrumbItem> 53 <BreadcrumbSeparator className="hidden md:block" /> 54 <BreadcrumbItem> 55 <BreadcrumbPage>ロール新規登録</BreadcrumbPage> 56 </BreadcrumbItem> 57 </BreadcrumbList> 58 </Breadcrumb> 59 </div> 60 </header> 61 62 <div className="max-w-xl p-4 pt-0"> 63 <Client /> 64 </div> 65 </> 66 ); 67}
  • 画面ルーティングは /masters/roles/new に統一し、テナント視点での「ロール」を一本化します。
  • SSR 側で guardHrefOrRedirect を実行して、未ログイン・権限不足時に即座に退避します。
  • このページでは DB からのロール読込は不要(custom 固定の新規)なので、Client の props は空です。
tsx
1// src/app/(protected)/masters/roles/new/client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import { toast } from "sonner"; 6 7import DepartmentRoleForm from "@/components/department-roles/department-role-form"; 8import type { DepartmentRoleCreateValues } from "@/lib/department-roles/schema"; 9import { createDepartmentRole } from "@/app/_actions/department-roles/create"; 10 11export default function NewDepartmentRoleClient() { 12 const router = useRouter(); 13 14 const handleSubmit = async (values: DepartmentRoleCreateValues) => { 15 const res = await createDepartmentRole(values); 16 17 if (res.ok) { 18 toast.success("ロールを作成しました", { 19 description: [ 20 `コード: ${values.code}`, 21 `表示名: ${values.name}`, 22 `優先度: ${values.priority}`, 23 `DL: ${values.canDownloadData ? "可" : "不可"}`, 24 `編集: ${values.canEditData ? "可" : "不可"}`, 25 ].join(" / "), 26 duration: 3000, 27 }); 28 router.push("/masters/roles"); 29 router.refresh(); 30 return; 31 } 32 33 // 失敗時:詳細はクライアント検証に委譲し、汎用メッセージを表示 34 toast.error( 35 res.message ?? "登録に失敗しました。入力内容を確認してください。", 36 { 37 duration: 3500, 38 }, 39 ); 40 }; 41 42 return ( 43 <DepartmentRoleForm 44 mode="create" 45 onSubmit={handleSubmit} 46 onCancel={() => history.back()} 47 /> 48 ); 49}
  • フォームは DepartmentRoleForm(Users と同設計)を再利用し、onSubmitServer Action を呼び出します。
  • 成功時:トースト → /masters/roles に遷移 → router.refresh() で一覧再描画。
  • 失敗時:クライアント側のフィールド別メッセージは Zod に委譲し、トーストは汎用メッセージに統一します。
  • any は未使用、型は DepartmentRoleCreateValues のみを受け取り、プロジェクトの型規約を順守しています。

2. 更新(DepartmentRole.update / Override 作成・編集)

この章では、ロールの編集ページ /masters/roles/[displayId] を実装します。
テナント視点のロールは「Role 行(ベース)」と「DepartmentRole 行(部署ローカル)」が 同一の一覧 に混在します。編集画面は次の2パターンに対応します。
対象画面に来る displayId編集内容保存時の処理
custom(部署ローカル独自ロール)DRxxxxxxxxcode / name / priority / can* / badgeColor / isEnabled / remarksDepartmentRole の該当レコードを 更新
override(ベース Role の上書き)RLxxxxxxxx(初回: DR 不在)/ DRxxxxxxxx(既に DR 有り)nameOverride / badgeColorOverride / isEnabled(※ 権限/priority は Role と同値)なければ作成(roleId あり) / あれば 更新。削除=「DR を削除して Role 素の状態に戻す」
初期状態では、ベース Roleoverride が存在しない 可能性があります。この場合、編集ページで override を新規作成 できるようにします。

要件整理(custom / override の編集ルール)

編集では 2 つの型 を明確に分けます。DepartmentRoleroleId 有無で判定可能ですが、UI からは判別フラグを渡すと実装が単純です。
種別入力項目備考
customdisplayId, code, name, priority, canDownloadData, canEditData, badgeColor, isEnabled, remarks既存の DR を編集。priority ≤ 99
overridedisplayId?(DR の場合), roleDisplayId?(RL の場合), nameOverride?, badgeColorOverride?, isEnabled初回は DR が無いので roleDisplayId 起点で 新規作成。isEnabled は部署での有効/無効。
  • 削除
    • custom: 「論理削除」が無い設計なら DELETE。復活は「新規作成」で代替。
    • override: DR を物理削除してベース Role に戻します。

Zod スキーマ(編集用の識別ユニオン)

  • 1章と同じ方針で、共通フィールドは単一定義に集約。
  • 編集は 識別子 kind: "custom" | "override" を持つユニオンで表現します。
  • RHF と組み合わせるため、input / values(確定型)を明示します。
ts
1// src/lib/department-roles/schema.ts (末尾に追加) 2 3// ─ 1章で作成した内容を省略 4 5/** ── 更新(識別ユニオン) ── */ 6const customEdit = z.object({ 7 kind: z.literal("custom"), 8 displayId: z.string().min(1, "表示IDの取得に失敗しました"), 9 code: codeSchema, 10 name: nameSchema, 11 priority: prioritySchema, 12 badgeColor: badgeColorSchema, 13 isEnabled: z.boolean(), 14 canDownloadData: z.boolean(), 15 canEditData: z.boolean(), 16 remarks: remarksSchema, 17}); 18 19const overrideEditExisting = z.object({ 20 kind: z.literal("override"), 21 // 既存 override を編集する場合 22 displayId: z.string().min(1, "表示IDの取得に失敗しました").optional(), 23 // 初回(DRなし)の場合は Role 側の displayId を持つ 24 roleDisplayId: z.string().min(1, "Role の表示IDが必要です").optional(), 25 nameOverride: z.string().min(0).max(DR_NAME_MAX).optional(), 26 badgeColorOverride: badgeColorSchema, 27 isEnabled: z.boolean(), 28}); 29 30export const departmentRoleUpdateSchema = z.union([customEdit, overrideEditExisting]); 31 32export type DepartmentRoleUpdateValues = z.infer<typeof departmentRoleUpdateSchema>; 33export type DepartmentRoleUpdateInput = z.input<typeof departmentRoleUpdateSchema>;
  • custom は 1章の新規と同じ項目。
  • overridenameOverride / badgeColorOverride / isEnabled のみ編集可能。
  • override の 初回作成 にも対応するため、displayId(DR 側)または roleDisplayId(RL 側)のどちらかを受け取れるようにしています(optional() を許容)。実装では どちらか必須 をアプリ側で担保します。

Server Action:update / delete(custom・override 兼用)

  • 認証・権限・部署解決は 1章と同様。
  • kind によって custom 更新override 更新(作成/更新) を分岐。
  • 削除は custom: DR 削除 / override: DR 削除で Role にロールバック
ts
1// src/app/_actions/department-roles/update.ts 2"use server"; 3 4import "server-only"; 5import { prisma } from "@/lib/database"; 6import { lookupSessionFromCookie } from "@/lib/auth/session"; 7import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 8import { 9 departmentRoleUpdateSchema, 10 type DepartmentRoleUpdateValues, 11} from "@/lib/department-roles/schema"; 12 13type ActionResult = { ok: true } | { ok: false; message: string }; 14 15export async function updateDepartmentRole( 16 values: DepartmentRoleUpdateValues, 17): Promise<ActionResult> { 18 // 1) 認証 19 const ses = await lookupSessionFromCookie(); 20 if (!ses.ok) return { ok: false, message: "認証が必要です。" }; 21 22 // 2) 権限 23 const snap = await getUserSnapshot(ses.userId); 24 if (!snap || !snap.canEditData) { 25 return { ok: false, message: "この操作を行う権限がありません。" }; 26 } 27 28 // 3) 所属部署 29 const me = await prisma.user.findUnique({ 30 where: { id: ses.userId }, 31 select: { departmentId: true }, 32 }); 33 if (!me?.departmentId) 34 return { ok: false, message: "部署情報を取得できませんでした。" }; 35 36 // 4) サーバ最終検証 37 const parsed = departmentRoleUpdateSchema.safeParse(values); 38 if (!parsed.success) 39 return { ok: false, message: "入力内容を確認してください。" }; 40 41 const v = parsed.data; 42 43 // 5) 分岐 44 if (v.kind === "custom") { 45 // 部署内コード重複(自分以外) 46 const dup = await prisma.departmentRole.findFirst({ 47 where: { 48 departmentId: me.departmentId, 49 code: v.code, 50 NOT: { displayId: v.displayId }, 51 }, 52 select: { id: true }, 53 }); 54 if (dup) 55 return { ok: false, message: "このコードは既に使用されています。" }; 56 57 try { 58 await prisma.departmentRole.update({ 59 where: { displayId: v.displayId }, 60 data: { 61 code: v.code, 62 name: v.name, 63 priority: v.priority, 64 badgeColor: v.badgeColor ?? null, 65 isEnabled: v.isEnabled, 66 canDownloadData: v.canDownloadData, 67 canEditData: v.canEditData, 68 remarks: v.remarks ?? null, 69 }, 70 }); 71 } catch (e) { 72 console.error("[updateDepartmentRole/custom] DB update failed:", e); 73 return { ok: false, message: "更新に失敗しました。" }; 74 } 75 return { ok: true }; 76 } 77 78 // override: 既存 or 初回 79 try { 80 if (v.displayId) { 81 // 既存 override を更新 82 await prisma.departmentRole.update({ 83 where: { displayId: v.displayId }, 84 data: { 85 isEnabled: v.isEnabled, 86 nameOverride: v.nameOverride ?? null, 87 badgeColorOverride: v.badgeColorOverride ?? null, 88 }, 89 }); 90 } else if (v.roleDisplayId) { 91 // 初回作成(roleId を解決して作成) 92 const role = await prisma.role.findUnique({ 93 where: { displayId: v.roleDisplayId }, 94 select: { id: true }, 95 }); 96 if (!role) 97 return { ok: false, message: "対象ロールを取得できませんでした。" }; 98 99 await prisma.departmentRole.create({ 100 data: { 101 departmentId: me.departmentId, 102 roleId: role.id, 103 isEnabled: v.isEnabled, 104 nameOverride: v.nameOverride ?? null, 105 badgeColorOverride: v.badgeColorOverride ?? null, 106 }, 107 }); 108 } else { 109 return { ok: false, message: "表示IDの指定が不足しています。" }; 110 } 111 } catch (e) { 112 console.error("[updateDepartmentRole/override] failed:", e); 113 return { ok: false, message: "更新に失敗しました。" }; 114 } 115 116 return { ok: true }; 117} 118 119export async function deleteDepartmentRole( 120 displayId: string, 121): Promise<ActionResult> { 122 // 1) 認証/権限 123 const ses = await lookupSessionFromCookie(); 124 if (!ses.ok) return { ok: false, message: "認証が必要です。" }; 125 const snap = await getUserSnapshot(ses.userId); 126 if (!snap || !snap.canEditData) { 127 return { ok: false, message: "この操作を行う権限がありません。" }; 128 } 129 130 // 2) 存在確認(custom or override どちらでも可) 131 const dr = await prisma.departmentRole.findUnique({ 132 where: { displayId }, 133 select: { id: true, roleId: true }, 134 }); 135 if (!dr) return { ok: false, message: "対象が見つかりません。" }; 136 137 // 3) 削除(custom も override も DELETE。override はベース Role に戻る効果) 138 try { 139 await prisma.departmentRole.delete({ where: { displayId } }); 140 } catch (e) { 141 console.error("[deleteDepartmentRole] DB delete failed:", e); 142 return { ok: false, message: "削除に失敗しました。" }; 143 } 144 145 return { ok: true }; 146}
  • custom の重複チェックdepartmentId + code のユニーク制約に合わせて事前確認。
  • override 初回roleDisplayId から roleId を解決して DR を作成 します。
  • 削除 は override/custom ともに DR を消すだけ。override の場合は自然と ベース Role の表示に戻ります。

UI:フォーム拡張(Create + Edit、識別ユニオン対応)

1章の DepartmentRoleForm を拡張し、mode: "create" | "edit"kind: "custom" | "override" を受けられるようにします。
Users フォームと同じ構成( 小さなフィールドを関数に分割 )で、 any 不使用zodResolver を活用します。 また、あわせて、ロールにcanEditDataがないユーザには編集ができないようにします。
tsx
1// src/components/department-roles/department-role-form.tsx 2"use client"; 3 4import * as React from "react"; 5import { useForm } from "react-hook-form"; 6import { zodResolver } from "@hookform/resolvers/zod"; 7 8import { 9 departmentRoleCreateSchema, 10 type DepartmentRoleCreateInput, 11 type DepartmentRoleCreateValues, 12 departmentRoleUpdateSchema, 13 type DepartmentRoleUpdateInput, 14 type DepartmentRoleUpdateValues, 15 DR_PRIORITY_MAX, 16} from "@/lib/department-roles/schema"; 17 18import { 19 Form, 20 FormField, 21 FormItem, 22 FormLabel, 23 FormControl, 24 FormMessage, 25 FormDescription, 26} from "@/components/ui/form"; 27import { Input } from "@/components/ui/input"; 28import { Switch } from "@/components/ui/switch"; 29import { Button } from "@/components/ui/button"; 30import { Card, CardContent, CardFooter } from "@/components/ui/card"; 31 32type BaseProps = { 33 onCancel?: () => void; 34 onDelete?: () => void; 35 readOnly?: boolean; 36}; 37 38type CreateProps = BaseProps & { 39 mode: "create"; 40 onSubmit: (values: DepartmentRoleCreateValues) => void; 41 initialValues?: never; 42}; 43 44type EditProps = BaseProps & { 45 mode: "edit"; 46 onSubmit: (values: DepartmentRoleUpdateValues) => void; 47 initialValues: DepartmentRoleUpdateValues; 48}; 49 50type Props = CreateProps | EditProps; 51 52export default function DepartmentRoleForm(props: Props) { 53 return props.mode === "create" ? ( 54 <CreateForm {...props} /> 55 ) : ( 56 <EditForm {...props} /> 57 ); 58} 59 60function CreateForm({ onSubmit, onCancel }: CreateProps) { 61 const form = useForm< 62 DepartmentRoleCreateInput, 63 undefined, 64 DepartmentRoleCreateValues 65 >({ 66 resolver: zodResolver(departmentRoleCreateSchema), 67 defaultValues: { 68 code: "", 69 name: "", 70 priority: 0, 71 badgeColor: "#666666", 72 isEnabled: true, 73 canDownloadData: false, 74 canEditData: false, 75 remarks: "", 76 }, 77 mode: "onBlur", 78 }); 79 80 const handleSubmit = form.handleSubmit(onSubmit); 81 82 return ( 83 <Form {...form}> 84 <form data-testid="department-role-form-create" onSubmit={handleSubmit}> 85 <Card className="w-full rounded-md"> 86 <CardContent className="space-y-6 pt-1"> 87 <CodeField /> 88 <NameField /> 89 <PriorityField /> 90 <BadgeColorField /> 91 <IsEnabledField /> 92 <CanDownloadField /> 93 <CanEditField /> 94 <RemarksField /> 95 </CardContent> 96 <CardFooter className="mt-4 flex gap-2"> 97 <Button 98 type="button" 99 variant="outline" 100 onClick={onCancel} 101 data-testid="cancel-btn" 102 className="cursor-pointer" 103 > 104 キャンセル 105 </Button> 106 <Button 107 type="submit" 108 data-testid="submit-create" 109 disabled={form.formState.isSubmitting} 110 className="cursor-pointer" 111 > 112 登録する 113 </Button> 114 </CardFooter> 115 </Card> 116 </form> 117 </Form> 118 ); 119} 120 121function EditForm({ 122 initialValues, 123 onSubmit, 124 onCancel, 125 onDelete, 126 readOnly = false, 127}: EditProps) { 128 const form = useForm< 129 DepartmentRoleUpdateInput, 130 undefined, 131 DepartmentRoleUpdateValues 132 >({ 133 resolver: zodResolver(departmentRoleUpdateSchema), 134 defaultValues: initialValues, 135 mode: "onBlur", 136 }); 137 138 const handleSubmit = form.handleSubmit(onSubmit); 139 const isCustom = initialValues.kind === "custom"; 140 141 return ( 142 <Form {...form}> 143 <form data-testid="department-role-form-edit" onSubmit={handleSubmit}> 144 <Card className="w-full rounded-md"> 145 <CardContent className="space-y-6 pt-1"> 146 {isCustom ? ( 147 <> 148 <DisplayIdField /> 149 <CodeField readOnly={readOnly} /> 150 <NameField readOnly={readOnly} /> 151 <PriorityField readOnly={readOnly} /> 152 <BadgeColorField readOnly={readOnly} /> 153 <IsEnabledField readOnly={readOnly} /> 154 <CanDownloadField readOnly={readOnly} /> 155 <CanEditField readOnly={readOnly} /> 156 <RemarksField readOnly={readOnly} /> 157 </> 158 ) : ( 159 <> 160 {/* override: 実効値をそのまま初期表示(テキスト+カラー) */} 161 <OverrideNameField readOnly={readOnly} /> 162 <OverrideBadgeColorField readOnly={readOnly} /> 163 <IsEnabledField readOnly={readOnly} /> 164 </> 165 )} 166 </CardContent> 167 168 <CardFooter className="mt-4 flex items-center justify-between"> 169 <div className="flex gap-2"> 170 <Button 171 type="button" 172 variant="outline" 173 onClick={onCancel} 174 data-testid="cancel-btn" 175 className="cursor-pointer" 176 > 177 キャンセル 178 </Button> 179 {!readOnly && ( 180 <Button 181 type="submit" 182 data-testid="submit-update" 183 disabled={form.formState.isSubmitting} 184 className="cursor-pointer" 185 > 186 更新する 187 </Button> 188 )} 189 </div> 190 {!readOnly && onDelete && ( 191 <Button 192 type="button" 193 variant="destructive" 194 onClick={onDelete} 195 data-testid="delete" 196 className="cursor-pointer" 197 > 198 削除する 199 </Button> 200 )} 201 </CardFooter> 202 </Card> 203 </form> 204 </Form> 205 ); 206} 207 208/* ===== 共通フィールド ===== */ 209 210function DisplayIdField() { 211 return ( 212 <FormField 213 name="displayId" 214 render={({ field }) => ( 215 <FormItem> 216 <FormLabel>表示ID</FormLabel> 217 <FormControl> 218 <Input 219 {...field} 220 readOnly 221 aria-readonly="true" 222 data-testid="displayId" 223 className="text-muted-foreground bg-muted border-none focus-visible:ring-0" 224 /> 225 </FormControl> 226 <FormDescription>DBで自動採番される表示IDです。</FormDescription> 227 <FormMessage /> 228 </FormItem> 229 )} 230 /> 231 ); 232} 233 234function CodeField({ readOnly = false }: { readOnly?: boolean }) { 235 return ( 236 <FormField 237 name="code" 238 render={({ field }) => ( 239 <FormItem> 240 <FormLabel className="font-semibold">コード *</FormLabel> 241 <FormControl> 242 <Input 243 {...field} 244 placeholder="ANALYST" 245 aria-label="コード" 246 autoComplete="off" 247 data-testid="code" 248 readOnly={readOnly} 249 aria-readonly={readOnly} 250 className={readOnly ? "bg-muted pointer-events-none" : ""} 251 /> 252 </FormControl> 253 <FormMessage data-testid="code-error" /> 254 </FormItem> 255 )} 256 /> 257 ); 258} 259 260function NameField({ readOnly = false }: { readOnly?: boolean }) { 261 return ( 262 <FormField 263 name="name" 264 render={({ field }) => ( 265 <FormItem> 266 <FormLabel className="font-semibold">表示名 *</FormLabel> 267 <FormControl> 268 <Input 269 {...field} 270 placeholder="分析担当" 271 aria-label="表示名" 272 autoComplete="off" 273 data-testid="name" 274 readOnly={readOnly} 275 aria-readonly={readOnly} 276 className={readOnly ? "bg-muted pointer-events-none" : ""} 277 /> 278 </FormControl> 279 <FormMessage data-testid="name-error" /> 280 </FormItem> 281 )} 282 /> 283 ); 284} 285 286function PriorityField({ readOnly = false }: { readOnly?: boolean }) { 287 return ( 288 <FormField 289 name="priority" 290 render={({ field }) => ( 291 <FormItem> 292 <FormLabel className="font-semibold">優先度 *</FormLabel> 293 <FormControl> 294 <Input 295 type="number" 296 inputMode="numeric" 297 min={0} 298 max={DR_PRIORITY_MAX} 299 step={1} 300 {...field} 301 value={field.value == null ? "" : String(field.value)} 302 onChange={(e) => 303 field.onChange(e.target.value === "" ? "" : e.target.value) 304 } 305 placeholder="20" 306 aria-label="優先度" 307 data-testid="priority" 308 readOnly={readOnly} 309 aria-readonly={readOnly} 310 className={readOnly ? "bg-muted pointer-events-none" : ""} 311 /> 312 </FormControl> 313 <FormDescription className="text-xs"> 314 0〜{DR_PRIORITY_MAX} の整数で入力してください。 315 </FormDescription> 316 <FormMessage data-testid="priority-error" /> 317 </FormItem> 318 )} 319 /> 320 ); 321} 322 323function BadgeColorField({ readOnly = false }: { readOnly?: boolean }) { 324 return ( 325 <FormField 326 name="badgeColor" 327 render={({ field }) => ( 328 <FormItem> 329 <FormLabel className="font-semibold">バッジ色</FormLabel> 330 <FormControl> 331 <Input 332 type="color" 333 {...field} 334 aria-label="バッジ色" 335 data-testid="badgeColor" 336 disabled={readOnly} 337 className={readOnly ? "opacity-70" : "cursor-pointer"} 338 /> 339 </FormControl> 340 <FormMessage data-testid="badgeColor-error" /> 341 </FormItem> 342 )} 343 /> 344 ); 345} 346 347function IsEnabledField({ readOnly = false }: { readOnly?: boolean }) { 348 return ( 349 <FormField 350 name="isEnabled" 351 render={({ field }) => ( 352 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 353 <div className="space-y-0.5"> 354 <FormLabel className="font-semibold">有効 *</FormLabel> 355 <FormDescription>ONで有効/OFFで無効</FormDescription> 356 </div> 357 <FormControl> 358 <Switch 359 name={field.name} 360 checked={Boolean(field.value)} 361 onCheckedChange={field.onChange} 362 aria-label="有効" 363 data-testid="isEnabled" 364 disabled={readOnly} 365 /> 366 </FormControl> 367 <FormMessage data-testid="isEnabled-error" /> 368 </FormItem> 369 )} 370 /> 371 ); 372} 373 374function CanDownloadField({ readOnly = false }: { readOnly?: boolean }) { 375 return ( 376 <FormField 377 name="canDownloadData" 378 render={({ field }) => ( 379 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 380 <FormLabel className="font-semibold">データDL可 *</FormLabel> 381 <FormControl> 382 <Switch 383 name={field.name} 384 checked={Boolean(field.value)} 385 onCheckedChange={field.onChange} 386 aria-label="データDL可" 387 data-testid="canDownloadData" 388 disabled={readOnly} 389 /> 390 </FormControl> 391 <FormMessage data-testid="canDownloadData-error" /> 392 </FormItem> 393 )} 394 /> 395 ); 396} 397 398function CanEditField({ readOnly = false }: { readOnly?: boolean }) { 399 return ( 400 <FormField 401 name="canEditData" 402 render={({ field }) => ( 403 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 404 <FormLabel className="font-semibold">データ編集可 *</FormLabel> 405 <FormControl> 406 <Switch 407 name={field.name} 408 checked={Boolean(field.value)} 409 onCheckedChange={field.onChange} 410 aria-label="データ編集可" 411 data-testid="canEditData" 412 disabled={readOnly} 413 /> 414 </FormControl> 415 <FormMessage data-testid="canEditData-error" /> 416 </FormItem> 417 )} 418 /> 419 ); 420} 421 422function RemarksField({ readOnly = false }: { readOnly?: boolean }) { 423 return ( 424 <FormField 425 name="remarks" 426 render={({ field }) => ( 427 <FormItem> 428 <FormLabel className="font-semibold">備考</FormLabel> 429 <FormControl> 430 <Input 431 {...field} 432 placeholder="メモなど" 433 aria-label="備考" 434 autoComplete="off" 435 data-testid="remarks" 436 readOnly={readOnly} 437 aria-readonly={readOnly} 438 className={readOnly ? "bg-muted pointer-events-none" : ""} 439 /> 440 </FormControl> 441 <FormMessage data-testid="remarks-error" /> 442 </FormItem> 443 )} 444 /> 445 ); 446} 447 448/* ===== override 用(そのまま初期表示) ===== */ 449 450function OverrideNameField({ readOnly = false }: { readOnly?: boolean }) { 451 return ( 452 <FormField 453 name="nameOverride" 454 render={({ field }) => ( 455 <FormItem> 456 <FormLabel className="font-semibold">表示名(上書き)</FormLabel> 457 <FormControl> 458 <Input 459 {...field} 460 placeholder="表示名(ベースを上書き)" 461 aria-label="表示名(上書き)" 462 autoComplete="off" 463 data-testid="nameOverride" 464 readOnly={readOnly} 465 aria-readonly={readOnly} 466 className={readOnly ? "bg-muted pointer-events-none" : ""} 467 /> 468 </FormControl> 469 <FormMessage data-testid="nameOverride-error" /> 470 </FormItem> 471 )} 472 /> 473 ); 474} 475 476function OverrideBadgeColorField({ readOnly = false }: { readOnly?: boolean }) { 477 return ( 478 <FormField 479 name="badgeColorOverride" 480 render={({ field }) => ( 481 <FormItem> 482 <FormLabel className="font-semibold">バッジ色(上書き)</FormLabel> 483 <FormControl> 484 <Input 485 type="color" 486 {...field} 487 aria-label="バッジ色(上書き)" 488 data-testid="badgeColorOverride" 489 disabled={readOnly} 490 className={readOnly ? "opacity-70" : "cursor-pointer"} 491 /> 492 </FormControl> 493 <FormMessage data-testid="badgeColorOverride-error" /> 494 </FormItem> 495 )} 496 /> 497 ); 498}
  • EditForminitialValues.kind で表示を切り替えます。
  • override 初回(DR 無)でも UI は同じ。roleDisplayIdページ側で initialValues に含めて 渡します。

画面:/masters/roles/[displayId](SSR ガード+初期値解決)

  • displayIdDRRL かで分岐し、 編集用初期値 を合成して Client に渡します。
  • Role 単体(override 無) の場合でも、kind: "override"initialValues を構成します(isEnabled: truenameOverride/badgeColorOverride: undefined)。
tsx
1// src/app/(protected)/masters/roles/[displayId]/page.tsx 2 3import type { Metadata } from "next"; 4import { notFound } from "next/navigation"; 5 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 { prisma } from "@/lib/database"; 18import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 19import Client from "./client"; 20 21import type { DepartmentRoleUpdateValues } from "@/lib/department-roles/schema"; 22 23export const metadata: Metadata = { 24 title: "ロール編集", 25 description: "部署ローカル(custom)/ベースロール上書き(override)の編集", 26}; 27 28export default async function Page({ 29 params, 30}: { 31 params: Promise<{ displayId: string }>; 32}) { 33 const { displayId } = await params; 34 35 // SSR ガード(userId を取得) 36 const viewer = await guardHrefOrRedirect(`/masters/roles/${displayId}`, "/"); 37 38 // 部署ID 39 const me = await prisma.user.findUnique({ 40 where: { id: viewer.userId }, 41 select: { departmentId: true }, 42 }); 43 if (!me?.departmentId) notFound(); 44 45 let initialValues: DepartmentRoleUpdateValues | null = null; 46 47 if (displayId.startsWith("DR")) { 48 // DR を直接取得(custom / override どちらでも) 49 const dr = await prisma.departmentRole.findUnique({ 50 where: { displayId }, 51 select: { 52 displayId: true, 53 departmentId: true, 54 roleId: true, 55 // custom 56 code: true, 57 name: true, 58 priority: true, 59 badgeColor: true, 60 canDownloadData: true, 61 canEditData: true, 62 isEnabled: true, 63 remarks: true, 64 // override 65 nameOverride: true, 66 badgeColorOverride: true, 67 role: { select: { displayId: true, name: true, badgeColor: true } }, 68 }, 69 }); 70 if (!dr || dr.departmentId !== me.departmentId) notFound(); 71 72 if (dr.roleId) { 73 // override(既存)。UI には「実効値」を初期表示させる 74 const effName = dr.nameOverride ?? dr.role?.name ?? ""; 75 const effColor = 76 dr.badgeColorOverride ?? dr.role?.badgeColor ?? "#000000"; 77 78 initialValues = { 79 kind: "override", 80 displayId: dr.displayId, 81 roleDisplayId: dr.role?.displayId, // 既存でも保持しておくと便利 82 isEnabled: dr.isEnabled, 83 nameOverride: effName, // ← 実効値をそのまま初期表示 84 badgeColorOverride: effColor, // ← 実効値をそのまま初期表示 85 }; 86 } else { 87 // custom 88 initialValues = { 89 kind: "custom", 90 displayId: dr.displayId, 91 code: dr.code ?? "", 92 name: dr.name ?? "", 93 priority: dr.priority ?? 0, 94 badgeColor: dr.badgeColor ?? "#000000", 95 isEnabled: dr.isEnabled, 96 canDownloadData: dr.canDownloadData ?? false, 97 canEditData: dr.canEditData ?? false, 98 remarks: dr.remarks ?? undefined, 99 }; 100 } 101 } else if (displayId.startsWith("RL")) { 102 // Role から編集(override 初回 or 既存) 103 const role = await prisma.role.findUnique({ 104 where: { displayId }, 105 select: { 106 displayId: true, 107 name: true, 108 badgeColor: true, 109 departmentRoles: { 110 where: { departmentId: me.departmentId, roleId: { not: null } }, 111 take: 1, 112 select: { 113 displayId: true, 114 isEnabled: true, 115 nameOverride: true, 116 badgeColorOverride: true, 117 }, 118 }, 119 }, 120 }); 121 if (!role) notFound(); 122 123 const ov = role.departmentRoles[0]; 124 if (ov) { 125 // 既存 override:実効値で初期表示(override 優先、なければ Role) 126 const effName = ov.nameOverride ?? role.name; 127 const effColor = ov.badgeColorOverride ?? role.badgeColor ?? "#000000"; 128 129 initialValues = { 130 kind: "override", 131 displayId: ov.displayId, 132 roleDisplayId: role.displayId, 133 isEnabled: ov.isEnabled, 134 nameOverride: effName, 135 badgeColorOverride: effColor, 136 }; 137 } else { 138 // 初回 override:Role 値をそのまま初期値に入れて表示 139 initialValues = { 140 kind: "override", 141 roleDisplayId: role.displayId, 142 isEnabled: true, 143 nameOverride: role.name, // ← そのまま表示 144 badgeColorOverride: role.badgeColor ?? "#000000", // ← そのまま表示 145 }; 146 } 147 } else { 148 notFound(); 149 } 150 151 if (!initialValues) notFound(); 152 153 return ( 154 <> 155 <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"> 156 <div className="flex items-center gap-2 px-4"> 157 <SidebarTrigger className="-ml-1" /> 158 <Separator 159 orientation="vertical" 160 className="mr-2 data-[orientation=vertical]:h-4" 161 /> 162 <Breadcrumb> 163 <BreadcrumbList> 164 <BreadcrumbItem className="hidden md:block"> 165 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 166 </BreadcrumbItem> 167 <BreadcrumbSeparator className="hidden md:block" /> 168 <BreadcrumbItem className="hidden md:block"> 169 <BreadcrumbLink href="/masters/roles"> 170 ロール管理 171 </BreadcrumbLink> 172 </BreadcrumbItem> 173 <BreadcrumbSeparator className="hidden md:block" /> 174 <BreadcrumbItem> 175 <BreadcrumbPage>ロール編集({displayId}</BreadcrumbPage> 176 </BreadcrumbItem> 177 </BreadcrumbList> 178 </Breadcrumb> 179 </div> 180 </header> 181 182 <div className="max-w-xl p-4 pt-0"> 183 <Client 184 initialValues={initialValues} 185 canEditData={viewer.canEditData} 186 /> 187 </div> 188 </> 189 ); 190}
  • DR 起点roleId の有無で custom / override を判定。
  • RL 起点 :同部署の override 有無で分岐。無ければ 初回 override 作成 の初期値を組み立てます。
tsx
1// src/app/(protected)/masters/roles/[displayId]/client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import { toast } from "sonner"; 6 7import DepartmentRoleForm from "@/components/department-roles/department-role-form"; 8import type { DepartmentRoleUpdateValues } from "@/lib/department-roles/schema"; 9import { 10 updateDepartmentRole, 11 deleteDepartmentRole, 12} from "@/app/_actions/department-roles/update"; 13 14type Props = { 15 initialValues: DepartmentRoleUpdateValues; 16 canEditData?: boolean; 17}; 18 19export default function EditDepartmentRoleClient({ 20 initialValues, 21 canEditData = false, 22}: Props) { 23 const router = useRouter(); 24 25 const handleSubmit = async (values: DepartmentRoleUpdateValues) => { 26 if (!canEditData) return; // 念のための保険(編集権がないと通常はボタン非表示) 27 // override の空入力は「ベース値を使う」= undefined に正規化 28 const normalized: DepartmentRoleUpdateValues = 29 values.kind === "override" 30 ? { 31 ...values, 32 nameOverride: 33 values.nameOverride && values.nameOverride.trim() !== "" 34 ? values.nameOverride 35 : undefined, 36 badgeColorOverride: 37 values.badgeColorOverride && 38 values.badgeColorOverride.trim() !== "" 39 ? values.badgeColorOverride 40 : undefined, 41 } 42 : values; 43 44 const res = await updateDepartmentRole(normalized); 45 46 if (res.ok) { 47 toast.success("ロールを更新しました", { duration: 3000 }); 48 router.push("/masters/roles"); 49 router.refresh(); 50 return; 51 } 52 toast.error(res.message ?? "更新に失敗しました。", { duration: 3500 }); 53 }; 54 55 const handleDelete = 56 canEditData && (initialValues.kind === "custom" || initialValues.displayId) 57 ? async () => { 58 const id = 59 initialValues.kind === "custom" 60 ? initialValues.displayId 61 : (initialValues.displayId as string); 62 const res = await deleteDepartmentRole(id); 63 if (res.ok) { 64 toast.success( 65 initialValues.kind === "custom" 66 ? "ロールを削除しました" 67 : "上書きを削除しました(ベースに戻ります)", 68 ); 69 router.push("/masters/roles"); 70 router.refresh(); 71 } else { 72 toast.error(res.message ?? "削除に失敗しました。"); 73 } 74 } 75 : undefined; 76 77 return ( 78 <DepartmentRoleForm 79 mode="edit" 80 initialValues={initialValues} 81 onSubmit={canEditData ? handleSubmit : () => {}} 82 onCancel={() => history.back()} 83 onDelete={canEditData ? handleDelete : undefined} 84 readOnly={!canEditData} 85 /> 86 ); 87}
  • 更新成功 :トースト → /masters/roles へ戻る → router.refresh()
  • 削除 :custom はレコード削除、override は DR を削除して Role 素に戻る
  • 初回 override は削除ボタンを出さない(対象が未作成だから)。

動作確認の観点(チェックリスト)

観点期待動作
DR(custom)編集code 重複は部署内で拒否。priority ≤ 99。有効/無効・権限フラグが保存される。
RL(override無)画面で上書き値を入力 → 保存で DR(override)新規作成。一覧で反映される。
RL/DR(override有)上書き値の変更・isEnabled 切り替えが保存される。削除で Role 素に戻る。
権限canEditData = false のユーザは SA で弾かれる(UI からも遷移不可が望ましい)。
直リンクguardHrefOrRedirect が未ログイン・権限不足を遮断する。
  • 編集画面は 1 画面で custom / override を統合 し、kind で分岐。
  • override 初回作成 (RL 起点)に対応しておくと、テナント運用の導線がシンプルになります。
  • サーバ側は 認証/権限/部署/入力 の 4 点を毎回チェックする共通骨格にすると、以降の CRUD も迷いません。

3. 一覧

部署にとっての「ロール」は RoleDepartmentRole同一テーブルに混在 して見えることが重要です。
ここではユーザ一覧の実装をテンプレートに、 /masters/rolesベースロール / 上書き / 部署カスタム をひとつの DataTable にまとめて表示・検索・ソート・CSV 出力できるようにします。
txt
1[一覧に並ぶ行のイメージ] 2 3┌──────────┬──────────┬────────┬──────────────┬─────────┬───────────┬──────────┬─────────────┐ 4│ 表示ID │ コード │ 種別 │ 表示名(実効) │ 優先度 │ 編集可 │ DL可 │ 状態(部署) │ ... 5├──────────┼──────────┼────────┼──────────────┼─────────┼───────────┼──────────┼─────────────┤ 6│ RL... │ ADMIN │ role │ 管理者 │ 100 │ ✅ │ ✅ │ 有効(=true) │ ← overrideなし 7│ RL... │ VIEWER │ override│ ビュー専用 │ 10 │ ❌ │ ❌ │ 無効 │ ← 部署のoverride適用 8│ DR... │ ANALYST │ custom │ 分析担当 │ 20 │ ✅ │ ❌ │ 有効 │ ← 部署ローカル作成 9└──────────┴──────────┴────────┴──────────────┴─────────┴───────────┴──────────┴─────────────┘ 10※ 編集リンクは kind=role/override → `/masters/roles/RL...` 11 kind=custom → `/masters/roles/DR...`

表示仕様(ベース+上書き+カスタムを統合)

値の出所(role 行)値の出所(custom 行)
表示IDRole.displayIdDepartmentRole.displayId
コードRole.codeDepartmentRole.code
種別(kind)override が存在すれば override、なければ role常に custom
表示名(実効)nameOverride ?? Role.nameDepartmentRole.name
バッジ色(実効)badgeColorOverride ?? Role.badgeColorDepartmentRole.badgeColor
優先度Role.priorityDepartmentRole.priority
編集可/ダウンロード可Role.canEditData / Role.canDownloadDataDepartmentRole.canEditData / canDownloadData
状態(部署での有効)override ? override.isEnabled : trueDepartmentRole.isEnabled
備考-DepartmentRole.remarks
編集リンク/masters/roles/{Role.displayId}/masters/roles/{DepartmentRole.displayId}
以降は SSR でデータを合成 → クライアントの DataTable に渡す という流れで実装します。

ロール種別(kind) フィルタの追加

ロール一覧では、ロール種別(kind) フィルタを利用します。まずは、フィルタとなる複数選択可能なリストファイルを作成します。
ts
1// src/app/(protected)/masters/roles/types-checklist.tsx 2"use client"; 3 4import * as React from "react"; 5import { Check } from "lucide-react"; 6import { Button } from "@/components/ui/button"; 7import { 8 Command, 9 CommandGroup, 10 CommandItem, 11 CommandInput, 12 CommandEmpty, 13} from "@/components/ui/command"; 14import { Separator } from "@/components/ui/separator"; 15 16export function TypesChecklist({ 17 value, 18 onChange, 19 options, // [{value:'role',label:'ベース'}, ...] 20 footer, 21}: { 22 value: string[]; 23 onChange: (next: string[]) => void; 24 options: { value: string; label: string }[]; 25 footer?: React.ReactNode; 26}) { 27 const [needle, setNeedle] = React.useState(""); 28 29 const toggle = (v: string) => { 30 const set = new Set(value); 31 if (set.has(v)) { 32 set.delete(v); 33 } else { 34 set.add(v); 35 } 36 onChange(Array.from(set)); 37 }; 38 39 const all = options.map((o) => o.value); 40 const allSelected = value.length === all.length; 41 const noneSelected = value.length === 0; 42 43 const filtered = React.useMemo(() => { 44 const q = needle.trim().toLowerCase(); 45 if (!q) return options; 46 return options.filter( 47 (o) => 48 o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q), 49 ); 50 }, [needle, options]); 51 52 return ( 53 <div className="flex max-h-[60vh] w-full flex-col"> 54 <div className="p-2"> 55 <Command shouldFilter={false}> 56 <CommandInput 57 value={needle} 58 onValueChange={setNeedle} 59 placeholder="種別を検索…" 60 /> 61 <CommandEmpty>該当する種別がありません</CommandEmpty> 62 <CommandGroup heading="種別(複数選択可)"> 63 {filtered.map((o) => { 64 const checked = value.includes(o.value); 65 return ( 66 <CommandItem 67 key={o.value} 68 onSelect={() => toggle(o.value)} 69 className="flex items-center gap-2" 70 > 71 <span 72 className="flex h-5 w-5 items-center justify-center rounded border" 73 aria-checked={checked} 74 role="checkbox" 75 > 76 {checked ? <Check className="h-3 w-3" /> : null} 77 </span> 78 <span className="truncate">{o.label}</span> 79 </CommandItem> 80 ); 81 })} 82 </CommandGroup> 83 </Command> 84 </div> 85 86 <Separator /> 87 88 <div className="flex items-center justify-between gap-2 p-2"> 89 <div className="flex gap-2"> 90 <Button 91 type="button" 92 variant="ghost" 93 size="sm" 94 onClick={() => onChange(all)} 95 disabled={allSelected} 96 className="cursor-pointer" 97 > 98 すべて 99 </Button> 100 <Button 101 type="button" 102 variant="ghost" 103 size="sm" 104 onClick={() => onChange([])} 105 disabled={noneSelected} 106 className="cursor-pointer" 107 > 108 クリア 109 </Button> 110 </div> 111 <div className="flex items-center">{footer}</div> 112 </div> 113 </div> 114 ); 115}
これは新規のフィルタとなるので、src/types/table-meta.d.tsにも追記します。
ts
1// src/types/table-meta.d.ts 2import "@tanstack/table-core"; 3 4declare module "@tanstack/table-core" { 5 interface TableMeta<TData extends RowData> { 6 onMoveUp?: (id: string, _row?: TData) => void; 7 onMoveDown?: (id: string, _row?: TData) => void; 8 /** 依頼を「再発行済み」にする */ 9 onIssue?: (id: string, _row?: TData) => void | Promise<void>; 10 /** 依頼を「拒否」にする */ 11 onReject?: (id: string, _row?: TData) => void | Promise<void>; 12 /** ★ 追加:メール変更申請を「承認」する */ 13 onApprove?: (id: string, _row?: TData) => void | Promise<void>; 14 // ▼ ユーザ一覧用(必要なものだけ) 15 roleOptions?: Array<{ value: string; label: string }>; 16 roles?: string[]; 17 setRoles?: (next: string[]) => void; 18 19 status?: "ALL" | "ACTIVE" | "INACTIVE"; 20 setStatus?: (next: "ALL" | "ACTIVE" | "INACTIVE") => void; 21 22 createdRange?: import("react-day-picker").DateRange | undefined; 23 setCreatedRange?: ( 24 r: import("react-day-picker").DateRange | undefined, 25 ) => void; 26 27 updatedRange?: import("react-day-picker").DateRange | undefined; 28 setUpdatedRange?: ( 29 r: import("react-day-picker").DateRange | undefined, 30 ) => void; 31 32 // ▼ ロール一覧用(masters/roles) 33 kindOptions?: Array<{ value: string; label: string }>; 34 kinds?: string[]; 35 setKinds?: (next: string[]) => void; 36 } 37} 38 39export {};
これで一覧で利用可能なフィルタが増えます。

カラムファイルの作成

tsx
1// src/app/(protected)/masters/roles/columns.tsx 2"use client"; 3 4import Link from "next/link"; 5import * as React from "react"; 6import type { ColumnDef, HeaderContext } from "@tanstack/react-table"; 7import { Badge } from "@/components/ui/badge"; 8import { Button } from "@/components/ui/button"; 9import { 10 Tooltip, 11 TooltipContent, 12 TooltipTrigger, 13} from "@/components/ui/tooltip"; 14import { SquarePen, SlidersVertical } from "lucide-react"; 15import { format } from "date-fns"; 16import { ja } from "date-fns/locale"; 17import { 18 Popover, 19 PopoverContent, 20 PopoverTrigger, 21} from "@/components/ui/popover"; 22import * as PopoverPrimitive from "@radix-ui/react-popover"; 23import { StatusFilter } from "@/components/filters/status-filter"; 24import { SortButton } from "@/components/datagrid/sort-button"; 25import { TypesChecklist } from "./types-checklist"; 26// ★ 追加:日付レンジフィルタ 27import { DateRangePicker } from "@/components/filters/date-range-picker"; 28 29export type DepartmentRoleRow = { 30 displayId: string; // RL... or DR... 31 code: string; // 表示用コード 32 kind: "role" | "override" | "custom"; 33 nameEffective: string; 34 badgeColorEffective: string | null; 35 priority: number; 36 canEditData: boolean; 37 canDownloadData: boolean; 38 isEnabledInDepartment: boolean; // 部署視点の有効 39 remarks: string | null; // customのみ 40 createdAt: Date; 41 updatedAt: Date; 42}; 43 44function fmt(dt: Date) { 45 return format(dt, "yyyy/MM/dd HH:mm", { locale: ja }); 46} 47 48function HeaderWithFilter({ 49 title, 50 active, 51 children, 52 contentClassName, 53 trailing, 54}: { 55 title: string; 56 active: boolean; 57 children: React.ReactNode; 58 contentClassName?: string; 59 trailing?: React.ReactNode; 60}) { 61 return ( 62 <div className="flex items-center gap-1"> 63 <span className="whitespace-nowrap">{title}</span> 64 <Popover> 65 <PopoverTrigger asChild> 66 <Button 67 type="button" 68 size="icon" 69 variant={active ? "default" : "outline"} 70 className="h-7 w-7 cursor-pointer" 71 aria-label={`${title}のフィルタ`} 72 title={`${title}のフィルタ`} 73 > 74 <SlidersVertical className="h-3.5 w-3.5" /> 75 </Button> 76 </PopoverTrigger> 77 <PopoverContent 78 align="end" 79 className={["p-0", contentClassName ?? "w-80"].join(" ")} 80 > 81 {children} 82 </PopoverContent> 83 </Popover> 84 {trailing ? <div className="ml-0.5">{trailing}</div> : null} 85 </div> 86 ); 87} 88 89function HeaderWithSort<TData, TValue>({ 90 title, 91 ctx, 92}: { 93 title: string; 94 ctx: HeaderContext<TData, TValue>; 95}) { 96 return ( 97 <div className="flex items-center gap-1"> 98 <span className="whitespace-nowrap">{title}</span> 99 <SortButton 100 column={ctx.column} 101 aria-label={`${title}でソート`} 102 title={`${title}でソート`} 103 /> 104 </div> 105 ); 106} 107 108export const columns: ColumnDef<DepartmentRoleRow>[] = [ 109 { 110 id: "actions", 111 header: "操作", 112 enableResizing: false, 113 size: 40, 114 enableSorting: false, 115 cell: ({ row }) => ( 116 <Tooltip> 117 <TooltipTrigger asChild> 118 <Button 119 asChild 120 size="icon" 121 variant="outline" 122 data-testid={`edit-${row.original.displayId}`} 123 className="size-8 cursor-pointer" 124 > 125 <Link href={`/masters/roles/${row.original.displayId}`}> 126 <SquarePen /> 127 </Link> 128 </Button> 129 </TooltipTrigger> 130 <TooltipContent> 131 <p>参照・編集</p> 132 </TooltipContent> 133 </Tooltip> 134 ), 135 }, 136 137 { 138 accessorKey: "displayId", 139 header: (ctx) => <HeaderWithSort title="表示ID" ctx={ctx} />, 140 cell: ({ row }) => ( 141 <span className="font-mono">{row.original.displayId}</span> 142 ), 143 }, 144 { 145 accessorKey: "code", 146 header: (ctx) => <HeaderWithSort title="コード" ctx={ctx} />, 147 }, 148 149 // 種別フィルタ(role/override/custom) 150 { 151 accessorKey: "kind", 152 header: (ctx) => { 153 const table = ctx.table; 154 const kindOptions = table.options.meta?.kindOptions ?? []; 155 const kinds = 156 table.options.meta?.kinds ?? 157 kindOptions.map((o: { value: string }) => o.value); 158 const setKinds = table.options.meta?.setKinds ?? (() => {}); 159 const active = kinds.length !== kindOptions.length; 160 161 return ( 162 <HeaderWithFilter 163 title="種別" 164 active={active} 165 contentClassName="w-[300px]" 166 trailing={ 167 <SortButton 168 column={ctx.column} 169 aria-label="種別でソート" 170 title="種別でソート" 171 /> 172 } 173 > 174 <TypesChecklist 175 value={kinds} 176 onChange={setKinds} 177 options={kindOptions} 178 footer={ 179 <PopoverPrimitive.Close asChild> 180 <Button 181 variant="ghost" 182 size="sm" 183 type="button" 184 className="cursor-pointer" 185 > 186 閉じる 187 </Button> 188 </PopoverPrimitive.Close> 189 } 190 /> 191 </HeaderWithFilter> 192 ); 193 }, 194 size: 64, 195 enableResizing: false, 196 cell: ({ row }) => { 197 const k = row.original.kind; 198 const label = 199 k === "role" ? "ベース" : k === "override" ? "上書き" : "部署ローカル"; 200 return ( 201 <Badge variant={k === "custom" ? "secondary" : "outline"}> 202 {label} 203 </Badge> 204 ); 205 }, 206 }, 207 208 // 表示名(実効)+色バッジ 209 { 210 accessorKey: "nameEffective", 211 header: (ctx) => <HeaderWithSort title="表示名" ctx={ctx} />, 212 cell: ({ row }) => { 213 const color = row.original.badgeColorEffective; 214 const style = color 215 ? { backgroundColor: color, color: "#fff", border: "none" } 216 : undefined; 217 return ( 218 <div className="flex items-center gap-2"> 219 <Badge 220 variant={color ? "secondary" : "outline"} 221 style={style} 222 title={color ?? ""} 223 > 224 {row.original.nameEffective} 225 </Badge> 226 </div> 227 ); 228 }, 229 }, 230 231 { 232 accessorKey: "priority", 233 header: (ctx) => <HeaderWithSort title="優先度" ctx={ctx} />, 234 size: 60, 235 }, 236 237 { 238 accessorKey: "canEditData", 239 header: (ctx) => <HeaderWithSort title="編集可" ctx={ctx} />, 240 size: 56, 241 cell: ({ row }) => (row.original.canEditData ? "✅" : "—"), 242 }, 243 { 244 accessorKey: "canDownloadData", 245 header: (ctx) => <HeaderWithSort title="DL可" ctx={ctx} />, 246 size: 56, 247 cell: ({ row }) => (row.original.canDownloadData ? "✅" : "—"), 248 }, 249 250 // 状態(部署での有効)フィルタ 251 { 252 accessorKey: "isEnabledInDepartment", 253 header: (ctx) => { 254 const table = ctx.table; 255 const status = table.options.meta?.status ?? "ALL"; 256 const setStatus: (next: "ALL" | "ACTIVE" | "INACTIVE") => void = 257 table.options.meta?.setStatus ?? (() => {}); 258 const active = status !== "ALL"; 259 return ( 260 <HeaderWithFilter 261 title="状態" 262 active={active} 263 contentClassName="w-[220px]" 264 trailing={ 265 <SortButton 266 column={ctx.column} 267 aria-label="状態でソート" 268 title="状態でソート" 269 /> 270 } 271 > 272 <StatusFilter value={status} onChange={setStatus} /> 273 </HeaderWithFilter> 274 ); 275 }, 276 size: 50, 277 enableResizing: false, 278 cell: ({ row }) => 279 row.original.isEnabledInDepartment ? ( 280 <Badge data-testid="badge-enabled">有効</Badge> 281 ) : ( 282 <Badge variant="outline" data-testid="badge-disabled"> 283 無効 284 </Badge> 285 ), 286 }, 287 288 { 289 accessorKey: "remarks", 290 header: (ctx) => <HeaderWithSort title="備考" ctx={ctx} />, 291 size: 150, 292 cell: ({ row }) => ( 293 <div className="max-w-[150px] truncate">{row.original.remarks}</div> 294 ), 295 }, 296 297 // ★ 登録日時(フィルタ+ソート) 298 { 299 accessorKey: "createdAt", 300 header: (ctx) => { 301 const table = ctx.table; 302 const r = table.options.meta?.createdRange; 303 const setR: ( 304 r: import("react-day-picker").DateRange | undefined, 305 ) => void = table.options.meta?.setCreatedRange ?? (() => {}); 306 const active = !!(r?.from || r?.to); 307 return ( 308 <HeaderWithFilter 309 title="登録日時" 310 active={active} 311 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]" 312 trailing={ 313 <SortButton 314 column={ctx.column} 315 aria-label="登録日時でソート" 316 title="登録日時でソート" 317 /> 318 } 319 > 320 <DateRangePicker label="登録日時" value={r} onChange={setR} /> 321 </HeaderWithFilter> 322 ); 323 }, 324 size: 120, 325 cell: ({ row }) => fmt(row.original.createdAt), 326 }, 327 328 // ★ 更新日時(フィルタ+ソート) 329 { 330 accessorKey: "updatedAt", 331 header: (ctx) => { 332 const table = ctx.table; 333 const r = table.options.meta?.updatedRange; 334 const setR: ( 335 r: import("react-day-picker").DateRange | undefined, 336 ) => void = table.options.meta?.setUpdatedRange ?? (() => {}); 337 const active = !!(r?.from || r?.to); 338 return ( 339 <HeaderWithFilter 340 title="更新日時" 341 active={active} 342 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]" 343 trailing={ 344 <SortButton 345 column={ctx.column} 346 aria-label="更新日時でソート" 347 title="更新日時でソート" 348 /> 349 } 350 > 351 <DateRangePicker label="更新日時" value={r} onChange={setR} /> 352 </HeaderWithFilter> 353 ); 354 }, 355 size: 120, 356 cell: ({ row }) => fmt(row.original.updatedAt), 357 }, 358 359 // 検索用の隠し列 360 { 361 id: "q", 362 accessorFn: (r) => 363 `${r.displayId} ${r.code} ${r.nameEffective} ${r.remarks ?? ""}`.toLowerCase(), 364 enableHiding: true, 365 enableSorting: false, 366 size: 0, 367 header: () => null, 368 cell: () => null, 369 }, 370];
列構成はユーザ一覧とほぼ同じですが、ロール特有の 種別(kind)・実効表示名/色・権限フラグ を追加しています。
編集リンクは displayId の接頭語(RL/DR)に依らず、常に /masters/roles/[displayId] を踏むだけでOKです。

データテーブルの作成

tsx
1// src/app/(protected)/masters/roles/data-table.tsx 2"use client"; 3 4import * as React from "react"; 5import type { 6 ColumnDef, 7 SortingState, 8 VisibilityState, 9} from "@tanstack/react-table"; 10import { 11 flexRender, 12 getCoreRowModel, 13 getPaginationRowModel, 14 getSortedRowModel, 15 useReactTable, 16} from "@tanstack/react-table"; 17import { Table } from "@/components/datagrid/table-container"; 18import { 19 TableBody, 20 TableCell, 21 TableHead, 22 TableHeader, 23 TableRow, 24} from "@/components/ui/table"; 25import type { DateRange } from "react-day-picker"; 26import { format } from "date-fns"; 27import { ja } from "date-fns/locale"; 28import type { DepartmentRoleRow } from "./columns"; 29 30import { useDatagridQueryState } from "@/lib/datagrid/use-datagrid-query-state"; 31import { usePersistentDatagridState } from "@/lib/datagrid/use-persistent-datagrid-state"; 32import { fromDateRange, toDateRange } from "@/lib/datagrid/date-io"; 33import { buildCsv, downloadCsv, fmtDateTime } from "@/lib/datagrid/csv"; 34 35import { DatagridToolbar } from "@/components/datagrid/datagrid-toolbar"; 36import { DatagridSummary } from "@/components/datagrid/datagrid-summary"; 37import { DatagridPagination } from "@/components/datagrid/datagrid-pagination"; 38 39type Props = { 40 columns: ColumnDef<DepartmentRoleRow, unknown>[]; 41 data: DepartmentRoleRow[]; 42 kindOptions: { value: string; label: string }[]; 43 canDownloadData?: boolean; 44 canEditData?: boolean; 45}; 46 47export default function DataTable({ 48 columns, 49 data, 50 kindOptions, 51 canDownloadData = false, 52 canEditData = false, 53}: Props) { 54 const [mounted, setMounted] = React.useState(false); 55 React.useEffect(() => setMounted(true), []); 56 57 const allColumnIds = React.useMemo( 58 () => 59 [ 60 "displayId", 61 "code", 62 "kind", 63 "nameEffective", 64 "priority", 65 "canEditData", 66 "canDownloadData", 67 "isEnabledInDepartment", 68 "remarks", 69 "createdAt", 70 "updatedAt", 71 ] as const, 72 [], 73 ); 74 type ColId = (typeof allColumnIds)[number]; 75 76 // URL同期(ロール一覧専用の名前空間) 77 const [queryState, setQueryState] = useDatagridQueryState( 78 "masters-roles", 79 { 80 q: "", 81 kinds: [] as string[], // 空配列=全種別 82 status: "ALL" as "ALL" | "ACTIVE" | "INACTIVE", 83 createdRange: undefined as { from?: string; to?: string } | undefined, 84 updatedRange: undefined as { from?: string; to?: string } | undefined, 85 cols: Array.from(allColumnIds) as ColId[], 86 }, 87 { persistKey: "masters-roles" }, 88 ); 89 90 const allKinds = React.useMemo( 91 () => kindOptions.map((o) => o.value), 92 [kindOptions], 93 ); 94 const kindsForFilter = queryState.kinds.length ? queryState.kinds : allKinds; 95 96 const createdRange = React.useMemo( 97 () => toDateRange(queryState.createdRange), 98 [queryState.createdRange], 99 ); 100 const updatedRange = React.useMemo( 101 () => toDateRange(queryState.updatedRange), 102 [queryState.updatedRange], 103 ); 104 105 const setQ = (v: string) => setQueryState((s) => ({ ...s, q: v })); 106 const setKinds = (next: string[]) => 107 setQueryState((s) => ({ ...s, kinds: next })); 108 const setStatus = (next: "ALL" | "ACTIVE" | "INACTIVE") => 109 setQueryState((s) => ({ ...s, status: next })); 110 const setCreatedRange = (r?: DateRange) => 111 setQueryState((s) => ({ ...s, createdRange: fromDateRange(r) })); 112 const setUpdatedRange = (r?: DateRange) => 113 setQueryState((s) => ({ ...s, updatedRange: fromDateRange(r) })); 114 const setVisibleColumnIds = (ids: ColId[]) => 115 setQueryState((s) => ({ ...s, cols: ids })); 116 117 // ページサイズの永続化 118 const [persisted, setPersisted] = usePersistentDatagridState( 119 "masters-roles", 120 { pageSize: 20 }, 121 ); 122 123 const [sorting, setSorting] = React.useState<SortingState>([ 124 { id: "priority", desc: false }, // 既定は優先度の昇順 125 ]); 126 127 const columnLabels = React.useMemo( 128 () => 129 ({ 130 displayId: "表示ID", 131 code: "コード", 132 kind: "種別", 133 nameEffective: "表示名", 134 priority: "優先度", 135 canEditData: "編集可", 136 canDownloadData: "DL可", 137 isEnabledInDepartment: "状態", 138 remarks: "備考", 139 createdAt: "登録日時", 140 updatedAt: "更新日時", 141 }) as const, 142 [], 143 ); 144 145 const effectiveVisibleColumnIds: ColId[] = mounted 146 ? (queryState.cols as ColId[]) 147 : (Array.from(allColumnIds) as ColId[]); 148 const columnVisibility = React.useMemo<VisibilityState>(() => { 149 const set = new Set(effectiveVisibleColumnIds); 150 return { 151 actions: true, 152 q: false, 153 displayId: set.has("displayId"), 154 code: set.has("code"), 155 kind: set.has("kind"), 156 nameEffective: set.has("nameEffective"), 157 priority: set.has("priority"), 158 canEditData: set.has("canEditData"), 159 canDownloadData: set.has("canDownloadData"), 160 isEnabledInDepartment: set.has("isEnabledInDepartment"), 161 remarks: set.has("remarks"), 162 createdAt: set.has("createdAt"), 163 updatedAt: set.has("updatedAt"), 164 }; 165 }, [effectiveVisibleColumnIds]); 166 167 // フィルタ 168 const filteredData = React.useMemo(() => { 169 const needle = queryState.q.trim().toLowerCase(); 170 const kindSet = new Set(kindsForFilter); 171 const inRange = (d: Date, r?: DateRange) => { 172 if (!r?.from && !r?.to) return true; 173 const ts = d.getTime(); 174 if (r?.from && ts < new Date(r.from).setHours(0, 0, 0, 0)) return false; 175 if (r?.to && ts > new Date(r.to).setHours(23, 59, 59, 999)) return false; 176 return true; 177 }; 178 179 return data.filter((r) => { 180 const passQ = 181 !needle || 182 `${r.displayId} ${r.code} ${r.nameEffective} ${r.remarks ?? ""}` 183 .toLowerCase() 184 .includes(needle); 185 const passKind = kindSet.has(r.kind); 186 const passStatus = 187 queryState.status === "ALL" || 188 (queryState.status === "ACTIVE" 189 ? r.isEnabledInDepartment 190 : !r.isEnabledInDepartment); 191 const passCreated = inRange(r.createdAt, createdRange); 192 const passUpdated = inRange(r.updatedAt, updatedRange); 193 return passQ && passKind && passStatus && passCreated && passUpdated; 194 }); 195 }, [ 196 data, 197 queryState.q, 198 queryState.status, 199 kindsForFilter, 200 createdRange, 201 updatedRange, 202 ]); 203 204 const table = useReactTable({ 205 data: filteredData, 206 columns, 207 state: { sorting, columnVisibility }, 208 onSortingChange: setSorting, 209 getCoreRowModel: getCoreRowModel(), 210 getSortedRowModel: getSortedRowModel(), 211 getPaginationRowModel: getPaginationRowModel(), 212 initialState: { pagination: { pageIndex: 0, pageSize: 20 } }, 213 meta: { 214 kindOptions, 215 kinds: kindsForFilter, 216 setKinds, 217 status: queryState.status, 218 setStatus, 219 createdRange, 220 setCreatedRange, 221 updatedRange, 222 setUpdatedRange, 223 }, 224 }); 225 226 React.useEffect(() => { 227 if (mounted) table.setPageSize(persisted.pageSize); 228 }, [mounted, persisted.pageSize, table]); 229 230 // CSV(可視列のみ) 231 const onDownloadCsv = React.useCallback(() => { 232 const visibleLeaf = table 233 .getVisibleLeafColumns() 234 .map((c) => c.id) 235 .filter((id) => id !== "actions" && id !== "q") as ColId[]; 236 237 const headers = visibleLeaf.map((id) => columnLabels[id]); 238 239 const rows = filteredData.map((r) => 240 visibleLeaf.map((id) => { 241 switch (id) { 242 case "displayId": 243 return r.displayId; 244 case "code": 245 return r.code; 246 case "kind": 247 return r.kind === "role" 248 ? "ベース" 249 : r.kind === "override" 250 ? "上書き" 251 : "部署ローカル"; 252 case "nameEffective": 253 return r.nameEffective; 254 case "priority": 255 return r.priority; 256 case "canEditData": 257 return r.canEditData ? "可" : "不可"; 258 case "canDownloadData": 259 return r.canDownloadData ? "可" : "不可"; 260 case "isEnabledInDepartment": 261 return r.isEnabledInDepartment ? "有効" : "無効"; 262 case "remarks": 263 return r.remarks ?? ""; 264 case "createdAt": 265 return fmtDateTime(r.createdAt); 266 case "updatedAt": 267 return fmtDateTime(r.updatedAt); 268 default: 269 return ""; 270 } 271 }), 272 ); 273 274 const csv = buildCsv(headers, rows); 275 const ts = format(new Date(), "yyyyMMdd_HHmmss", { locale: ja }); 276 downloadCsv(`roles_${ts}.csv`, csv); 277 }, [filteredData, table, columnLabels]); 278 279 const kindText = 280 queryState.kinds.length === 0 281 ? "種別: すべて" 282 : `種別: ${queryState.kinds 283 .map( 284 (v) => 285 new Map(kindOptions.map((o) => [o.value, o.label])).get(v) ?? v, 286 ) 287 .join(", ")}`; 288 const statusText = 289 queryState.status === "ALL" 290 ? "状態: すべて" 291 : queryState.status === "ACTIVE" 292 ? "状態: 有効" 293 : "状態: 無効"; 294 const visibleColsText = ( 295 mounted ? effectiveVisibleColumnIds : (Array.from(allColumnIds) as ColId[]) 296 ) 297 .map((id) => columnLabels[id]) 298 .join(", "); 299 300 const filteredCount = filteredData.length; 301 302 return ( 303 <div className="space-y-3"> 304 <DatagridToolbar<ColId> 305 q={queryState.q} 306 onChangeQ={setQ} 307 columnOptions={allColumnIds.map((id) => ({ 308 value: id, 309 label: columnLabels[id], 310 }))} 311 visibleColumnIds={effectiveVisibleColumnIds} 312 onChangeVisibleColumns={setVisibleColumnIds} 313 canDownloadData={canDownloadData} 314 onDownloadCsv={onDownloadCsv} 315 canEditData={canEditData} 316 newHref="/masters/roles/new" 317 /> 318 319 <div className="flex items-center justify-between gap-3"> 320 <div className="text-sm" data-testid="count"> 321 表示件数: {filteredCount}322 </div> 323 <div className="flex max-w-[60%] items-center justify-end gap-2"> 324 <DatagridSummary 325 mounted={mounted} 326 roleText={kindText} 327 statusText={statusText} 328 createdRangeISO={queryState.createdRange} 329 updatedRangeISO={queryState.updatedRange} 330 createdRange={createdRange} 331 updatedRange={updatedRange} 332 visibleColsText={visibleColsText} 333 /> 334 <button 335 type="button" 336 className="text-muted-foreground shrink-0 cursor-pointer text-xs underline" 337 onClick={() => { 338 setQueryState((s) => ({ 339 ...s, 340 q: "", 341 kinds: [], 342 status: "ALL", 343 createdRange: undefined, 344 updatedRange: undefined, 345 cols: Array.from(allColumnIds) as ColId[], 346 })); 347 setPersisted((p) => ({ ...p, pageSize: 20 })); 348 table.setPageSize(20); 349 }} 350 title="全フィルタ解除" 351 > 352 全フィルタ解除 353 </button> 354 </div> 355 </div> 356 357 <div className="overflow-x-auto rounded-md border pb-1"> 358 <Table 359 className="w-full" 360 data-testid="roles-table" 361 containerClassName={ 362 !canDownloadData && !canEditData 363 ? "max-h-[calc(100svh_-_244px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1" 364 : "max-h-[calc(100svh_-_284px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1" 365 } 366 > 367 <TableHeader className="bg-muted/60 supports-[backdrop-filter]:bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur"> 368 {table.getHeaderGroups().map((hg) => ( 369 <TableRow key={hg.id}> 370 {hg.headers.map((header) => ( 371 <TableHead 372 key={header.id} 373 style={{ width: header.column.getSize() }} 374 > 375 {header.isPlaceholder 376 ? null 377 : flexRender( 378 header.column.columnDef.header, 379 header.getContext(), 380 )} 381 </TableHead> 382 ))} 383 </TableRow> 384 ))} 385 </TableHeader> 386 <TableBody> 387 {table.getRowModel().rows.length ? ( 388 table.getRowModel().rows.map((row) => ( 389 <TableRow 390 key={row.id} 391 data-testid={`row-${(row.original as DepartmentRoleRow).displayId}`} 392 > 393 {row.getVisibleCells().map((cell) => ( 394 <TableCell 395 key={cell.id} 396 style={{ width: cell.column.getSize() }} 397 > 398 {flexRender( 399 cell.column.columnDef.cell, 400 cell.getContext(), 401 )} 402 </TableCell> 403 ))} 404 </TableRow> 405 )) 406 ) : ( 407 <TableRow> 408 <TableCell 409 colSpan={table.getAllColumns().length} 410 className="text-muted-foreground py-10 text-center text-sm" 411 > 412 条件に一致するロールが見つかりませんでした。 413 </TableCell> 414 </TableRow> 415 )} 416 </TableBody> 417 </Table> 418 </div> 419 420 <DatagridPagination<DepartmentRoleRow> 421 table={table} 422 pageSize={table.getState().pagination.pageSize} 423 onChangePageSize={(n) => { 424 table.setPageSize(n); 425 setPersisted((p) => ({ ...p, pageSize: n })); 426 }} 427 /> 428 </div> 429 ); 430}
DataTable はユーザ一覧をほぼ踏襲しつつ、URL 名前空間を masters-roles に変更、種別(kind) フィルタ を追加しました。
CSV は可視列のみを出力し、ファイル名は roles_YYYYMMDD_HHmmss.csv です。

Pageファイルの作成

tsx
1// src/app/(protected)/masters/roles/page.tsx 2import type { Metadata } from "next"; 3import { SidebarTrigger } from "@/components/ui/sidebar"; 4import { 5 Breadcrumb, 6 BreadcrumbItem, 7 BreadcrumbLink, 8 BreadcrumbList, 9 BreadcrumbPage, 10 BreadcrumbSeparator, 11} from "@/components/ui/breadcrumb"; 12import { Separator } from "@/components/ui/separator"; 13import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 14import { prisma } from "@/lib/database"; 15import DataTable from "./data-table"; 16import { columns, type DepartmentRoleRow } from "./columns"; 17 18export const metadata: Metadata = { 19 title: "ロール一覧", 20 description: 21 "部署視点でのロール一覧(Role + DepartmentRole)を混在表示。検索/フィルタ/CSV対応", 22}; 23 24export default async function Page() { 25 // 1) SSRガード(viewer には userId / 権限が入る) 26 const viewer = await guardHrefOrRedirect("/masters/roles", "/"); 27 28 // 2) 自部署ID 29 const me = await prisma.user.findUnique({ 30 where: { id: viewer.userId }, 31 select: { departmentId: true }, 32 }); 33 if (!me?.departmentId) return null; 34 35 // 3) ベースRole(当該部署の override を1件だけ同時取得) 36 const base = await prisma.role.findMany({ 37 where: { isActive: true }, 38 orderBy: { priority: "asc" }, 39 select: { 40 displayId: true, 41 code: true, 42 name: true, 43 badgeColor: true, 44 priority: true, 45 canEditData: true, 46 canDownloadData: true, 47 createdAt: true, 48 updatedAt: true, 49 departmentRoles: { 50 where: { departmentId: me.departmentId, roleId: { not: null } }, 51 take: 1, 52 select: { 53 displayId: true, 54 isEnabled: true, 55 nameOverride: true, 56 badgeColorOverride: true, 57 }, 58 }, 59 }, 60 }); 61 62 // 4) 部署カスタム(roleId == null のみ) 63 const customs = await prisma.departmentRole.findMany({ 64 where: { departmentId: me.departmentId, roleId: null }, 65 orderBy: { priority: "asc" }, 66 select: { 67 displayId: true, 68 code: true, 69 name: true, 70 badgeColor: true, 71 priority: true, 72 canEditData: true, 73 canDownloadData: true, 74 isEnabled: true, 75 remarks: true, 76 createdAt: true, 77 updatedAt: true, 78 }, 79 }); 80 81 // 5) UI行へ整形(kind / 実効表示名・色を組み立て) 82 const fromRole: DepartmentRoleRow[] = base.map((r) => { 83 const ov = r.departmentRoles[0]; 84 return { 85 displayId: r.displayId, 86 code: r.code, 87 kind: ov ? "override" : "role", 88 nameEffective: ov?.nameOverride ?? r.name, 89 badgeColorEffective: ov?.badgeColorOverride ?? r.badgeColor ?? null, 90 priority: r.priority, 91 canEditData: r.canEditData, 92 canDownloadData: r.canDownloadData, 93 isEnabledInDepartment: ov ? ov.isEnabled : true, 94 remarks: null, 95 createdAt: r.createdAt, 96 updatedAt: r.updatedAt, 97 }; 98 }); 99 100 const fromCustom: DepartmentRoleRow[] = customs.map((dr) => ({ 101 displayId: dr.displayId, 102 code: dr.code ?? "", 103 kind: "custom", 104 nameEffective: dr.name ?? "", 105 badgeColorEffective: dr.badgeColor ?? null, 106 priority: dr.priority ?? 0, 107 canEditData: dr.canEditData ?? false, 108 canDownloadData: dr.canDownloadData ?? false, 109 isEnabledInDepartment: dr.isEnabled, 110 remarks: dr.remarks ?? null, 111 createdAt: dr.createdAt, 112 updatedAt: dr.updatedAt, 113 })); 114 115 const rows: DepartmentRoleRow[] = [...fromRole, ...fromCustom]; 116 117 // 6) 種別フィルタ用の選択肢(固定3種) 118 const kindOptions = [ 119 { value: "role", label: "ベース" }, 120 { value: "override", label: "上書き" }, 121 { value: "custom", label: "部署ローカル" }, 122 ] as const; 123 124 return ( 125 <> 126 <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"> 127 <div className="flex items-center gap-2 px-4"> 128 <SidebarTrigger className="-ml-1" /> 129 <Separator 130 orientation="vertical" 131 className="mr-2 data-[orientation=vertical]:h-4" 132 /> 133 <Breadcrumb> 134 <BreadcrumbList> 135 <BreadcrumbItem className="hidden md:block"> 136 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 137 </BreadcrumbItem> 138 <BreadcrumbSeparator className="hidden md:block" /> 139 <BreadcrumbItem> 140 <BreadcrumbPage>ロール一覧</BreadcrumbPage> 141 </BreadcrumbItem> 142 </BreadcrumbList> 143 </Breadcrumb> 144 </div> 145 </header> 146 147 <div className="w-full max-w-[1729px] p-4 pt-0"> 148 <DataTable 149 columns={columns} 150 data={rows} 151 kindOptions={ 152 kindOptions as unknown as { value: string; label: string }[] 153 } 154 canDownloadData={viewer.canDownloadData} 155 canEditData={viewer.canEditData} 156 /> 157 </div> 158 </> 159 ); 160}
上記では、RoleDepartmentRole(custom) を別々に取得し、一覧用 Row 型 DepartmentRoleRow に正規化してから結合しています。
override が存在するベースロールは kind: "override" として表示し、表示名/色/有効 を上書き値で合成します。

動作確認

  1. 初期表示/masters/roles を開くと、部署視点での ベース + 上書き + カスタム が混在表示されます。
  2. フィルタ
    • ヘッダの「種別」で ベース/上書き/部署ローカル を複数選択で絞り込み。
    • 「状態」で 有効/無効 を切り替え。
    • キーワード・日付レンジ(登録/更新)もユーザ一覧と同様に動作。
  3. 編集:行頭のアイコンから /masters/roles/[displayId] へ遷移(RL は上書き編集、DR はcustom編集)。
  4. CSV:可視列のみ出力。badge 色や状態などは文字列化されます。
  5. 権限制御canEditData「新規登録」 ボタンの表示切替、canDownloadData で CSV ボタンの表示切替。
これで、テナント側の認識に沿った 唯一のロール一覧 が完成です。ユーザ一覧と同じ操作感で運用でき、今後の拡張(列追加・フィルタ拡張)も同じパターンで進められます。

4. まとめと次回予告

今回の記事では、前編で導入した DepartmentRoleテーブル を管理画面から安全に操作できるようにしました。
新規登録・更新・一覧を実装し、部署ごとのロール運用が実務的に可能な状態になりました。

今回実装した機能

今回取り組んだのは、以下の3つの機能です。 それぞれ、Server Action と UI を組み合わせて構築しました。
txt
1[今回完成した流れ] 2 3新規登録(DepartmentRole.custom) 45更新(DepartmentRole.update / override 作成・編集) 67一覧(Role + DepartmentRole を統合表示)
機能ごとの成果を表に整理すると次のようになります。
機能区分対象実現内容
新規登録custom 専用部署独自ロールを作成。priority ≤ 99 や code重複をUI/DBで二重チェック。
更新custom / overridecustomは通常編集、overrideはRoleを上書き。権限なしユーザは参照専用に制御。
一覧role/custom混在RoleとDepartmentRoleを統合表示。種別・状態・検索・CSV出力に対応。
  • 部署管理者は「ロール一覧画面がすべて」という認識で運用可能になった
  • 一覧に Role と DepartmentRole が同居することで、override/custom を意識せず直感的に操作できる
  • 権限がないユーザは参照のみ可能となり、誤操作や越権操作を防げる

次回予告:ユーザ管理と実効ロールの統合

次回は、完成した DepartmentRole の仕組みを ユーザ管理画面へ統合 します。
現状、ユーザ登録・更新・一覧・プロフィール表示では Role テーブルの情報を直接参照していますが、これを 実効ロール(Role + DepartmentRole) に切り替えます。
次回「管理画面フォーマット開発編 #9」では、このリファクタリングを詳細に扱い、 ユーザ登録・更新・参照のすべてで「実効ロール」が正しく反映される状態を目指します。

参考文献

この記事で利用したライブラリや参考にした公式ドキュメントをまとめます。
実装の正確性・再現性を担保するため、バージョンや用途を明記しています。

UI / フロントエンド関連

ライブラリURL用途
Reacthttps://react.dev/UI 構築の基盤
Next.jshttps://nextjs.org/docsApp Router, SSR/SA 機能の利用
shadcn/uihttps://ui.shadcn.com/Radix ベースの UI コンポーネント
Tailwind CSShttps://tailwindcss.com/docsユーティリティファースト CSS

フォーム / バリデーション関連

ライブラリURL用途
React Hook Formhttps://react-hook-form.com/フォーム管理、入力制御
Zodhttps://zod.dev/スキーマ定義と実行時バリデーション
@hookform/resolvers/zodhttps://react-hook-form.com/docs/useform/#resolverRHF と Zod の統合

データアクセス / バックエンド関連

ライブラリURL用途
Prismahttps://www.prisma.io/docsORM、DB アクセス、スキーマと型の自動生成
PostgreSQLhttps://www.postgresql.org/docs/DB。priority 制約や XOR 制約を含む

データグリッド / 一覧表示関連

ライブラリURL用途
@tanstack/react-tablehttps://tanstack.com/table/v8/docs/guide/introductionDataTable 実装の基盤
date-fnshttps://date-fns.org/日付処理、フォーマット
react-day-pickerhttps://react-day-picker.js.org/日付レンジフィルタ UI

本記事に関連する DELOGs 内記事

以上の資料を参考に、DepartmentRole の 新規登録・更新・一覧表示 を UI/Server Action 両面から実装しました。
次回の記事では、ユーザ管理画面に「実効ロール」を統合していきます。
この記事の執筆・編集担当
DE

松本 孝太郎

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

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