DELOGs
[管理画面フォーマット開発編 #10] メニュー管理UIをDB連携する

管理画面フォーマット開発編 #10
メニュー管理UIをDB連携する

グローバルで一貫したMenuテーブルを保ちながら、部署ごとにメニュー表示をカスタマイズ

初回公開日

最終更新日

0. はじめに

本記事では、これまでモックで動かしていたサイドバーのナビゲーションを データベース連携 に移行し、部署ごとの「可視/非表示」「並び順」上書き、および 権限ガード(RBAC) と統合するまでを整理します。
結果として、サーバサイドで合成したメニューをレイアウトから供給し、UIでは一覧(/masters/menus)で安全に操作・確認できる構成に刷新します。
txt
1[Before] [After] 2AppSidebar ──(mock records)──▶ render layout.tsx ──▶ fetchMenusForDepartment(dept) 3guard(core) ─(mock records)──▶ decide └── AppSidebar(records) ─ RBAC filter & hidden除外 4 guard.async(href, user, dept) ──▶ decide with DB 5 /masters/menus ──▶ 部署上書き操作(hidden/sort) 6 └── revalidateTag(menus:dept:*)
上の文字図は、モック前提の分散実装を「DBを唯一の情報源(Single Source of Truth)」に寄せたイメージです。レイアウト段階で部署別に合成済みメニューを取得し、サイドバーとガードの両方が同じソースを参照するため、表示とアクセス制御の不一致 が起きにくくなります。

本記事で到達するゴール

  • メニュー定義を Menu / DepartmentMenu に分離し、部署ごとに hidden / sortOrder / isEnabled を上書きできる。
  • レイアウト(RSC)で部署別メニューを取得し、AppSidebar にレコードを渡して描画する。
  • guard は DBメニューに基づき一致判定し、必要優先度を決定する(NOT_FOUND / FORBIDDEN を厳密化)。
  • 管理UI(/masters/menus)は、テンプレ hidden=false のみを対象として表示。
    • 「可視」は hidden の反転(visible = !hidden)で操作。
    • lockHiddenOverride により、重要メニューの非表示化を禁止。
    • 行単位で操作中は LoaderCircle を表示し、チラつきを抑える。
  • しきい値(minPriority)は 親→子→孫 の継承ルールで表示値を補完し、CSVにも反映。
  • キャッシュは部署タグで管理し、操作後は revalidateTag で確実に反映。

読み進める前に

本記事は「管理画面フォーマット開発編」の一部として進めています。 「管理画面フォーマット開発編」の過去記事からの続きとしてソースコードの改修や新規作成を実施しています。

キーとなる変更点の対応関係(抜粋)

目的旧(モック)新(DB連携)
メニューの出所menu.mock.tsMenu + DepartmentMenu(Prisma)
サイドバーへの供給クライアント側で毎回取得layout(RSC)で fetch → AppSidebar に props
権限ガードdecideGuard (モック前提)decideGuardAsync (DBメニューで判定)
可視/非表示の運用画面に依存hidden + hiddenOverride(lock 可)
並び順ソート固定sortOrder + 部署上書き + 兄弟swap
誤操作の回避画面ルールlockHiddenOverride(DBで強制)
キャッシュ制御なしunstable_cache + revalidateTag
この後の章では、データモデルの追加から描画・判定の統合、管理UIの操作性改善、シードデータ投入までを順に解説していきます。まずは DepartmentMenu の設計から入ります。

1. DepartmentMenuテーブルの設計と追加

これまでの管理画面では、Menu テーブル自体が「全社共通テンプレート」として扱われており、
部署ごとに「どのメニューを非表示にするか」「順序をどう並べ替えるか」といった上書きはできませんでした。
しかし、実際の運用を考えると次のような要件が出てきます。
シーン必要な制御補足
部署単位で一部メニューを隠したいhiddenOverride例:一般部署では「マスタ管理」を非表示にしたい
部署ごとに並び順を変えたいsortOrder業務フローに合わせて順番をカスタマイズしたい
メニューを一時的に無効化したいisEnabled一部機能を試験的に非表示にしたい
こうした要件に対応するため、新たに DepartmentMenu モデル を追加しました。
このモデルは、Menu テーブルをテンプレートとして参照し、部署単位の上書き情報だけを保持する構成です。
txt
1Department 1 ──── * DepartmentMenu * ──── 1 Menu
上記のように「多対多(中間)」の関係を DepartmentMenu が担い、
部署ごとに Menu の「可視・順序・有効状態」を個別に上書きできる仕組みです。
親子関係や href、優先度などの構造はすべてテンプレート側 (Menu) に保持されます。

Prismaモデルの変更点

DepartmentMenu モデルそれぞれに逆リレーションを追加し、
新たに DepartmentMenu モデルを定義しました。
下記は差分を反映した Prisma スキーマです。
prisma
1// prisma/schema.prisma 2 3 model Department { 4 id String @id @default(uuid()) 5 ... 6 emailChangeRequests EmailChangeRequest[] 7+ /// 逆リレーション: 部署ごとのメニュー上書き 8+ departmentMenus DepartmentMenu[] 9 10 @@index([branchId]) 11 @@index([isActive]) 12 @@index([createdAt]) 13 } 14 15 model Menu { 16 id String @id @default(uuid()) 17 ... 18 hidden Boolean @default(false) 19+ lockHiddenOverride Boolean @default(false) 20 21 // リレーション(自己参照) 22 parent Menu? @relation("MenuToMenu", fields: [parentId], references: [id], onDelete: Restrict) 23 children Menu[] @relation("MenuToMenu") 24+ /// 逆リレーション: 部署ごとのメニュー上書き 25+ departmentMenus DepartmentMenu[] 26 27 @@index([parentId]) 28 @@index([minPriority]) 29 @@index([isActive]) 30 @@index([sortOrder]) 31 @@index([createdAt]) 32 @@index([hidden]) 33 } 34 35+// ============================== 36+// DepartmentMenu(部署ごとの上書き) 37+// - Menu は全社テンプレ。部署側の可視/hidden/並び順のみ上書きする 38+// - 親子関係や href/minPriority はテンプレ側を使用 39+// ============================== 40+model DepartmentMenu { 41+ id String @id @default(uuid()) 42+ createdAt DateTime @default(now()) @db.Timestamptz 43+ updatedAt DateTime @updatedAt @db.Timestamptz 44+ 45+ departmentId String 46+ menuId String 47+ 48+ /// 有効/無効の上書き(null はテンプレ既定=有効を採用) 49+ isEnabled Boolean? 50+ /// hidden の上書き(null はテンプレの hidden を採用) 51+ hiddenOverride Boolean? 52+ /// 兄弟内の並び順(null はテンプレの sortOrder を採用) 53+ sortOrder Int? 54+ remarks String? 55+ 56+ // リレーション 57+ department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 58+ menu Menu @relation(fields: [menuId], references: [id], onDelete: Restrict) 59+ 60+ // 制約・索引 61+ @@unique([departmentId, menuId]) 62+ @@index([departmentId]) 63+ @@index([menuId]) 64+ @@index([isEnabled]) 65+}
このモデルでは @@unique([departmentId, menuId]) によって
「同じ部署・同じメニューの上書きレコードは1件だけ」という一意制約を設定しています。
さらに、onDelete: Restrict により、部署やメニュー削除時の参照整合性を保証しています。

主なカラムと役割

カラム名意味備考
isEnabledBoolean?有効/無効の上書きnull の場合はテンプレート既定(=有効)を使用
hiddenOverrideBoolean?可視/非表示の上書きnull の場合はテンプレートの hidden 値を使用
sortOrderInt?並び順の上書き未設定時はテンプレートの順序を継承
remarksString?管理用メモ上書き理由などの記録に利用
また、テンプレート側の Menu モデルには lockHiddenOverride カラムを追加しています。 これにより、「このメニューは非表示にしてはいけない」という安全装置を設定できるようになりました。

マイグレーションと型更新

zsh
1npx prisma migrate dev --name add-department-menu-lockhidden 2npx prisma generate
マイグレーションを実行すると DepartmentMenu テーブルが生成され、
Prisma Client に新しい型定義が反映されます。
以降の実装ではこの DepartmentMenu を用いて、部署単位で柔軟な上書きが可能になります。

設計のポイント

  • null 値を「テンプレート継承」として扱う設計にすることで、上書きがない場合もデフォルト動作を保てる。
  • 部署ごとの設定をテンプレート本体と分離し、構成の安定性と柔軟性を両立。
  • lockHiddenOverride によって、誤操作で重要メニューを隠すリスクを防止。
  • 一意制約とインデックス設計により、検索・更新パフォーマンスを確保。
この章では、データモデルとしての基盤を整備しました。
次の章では、この DepartmentMenu を利用して実際にメニュー情報を取得・合成する仕組みを作ります。

2. Menuテーブルの拡張と lockHiddenOverride

この章では、Menu テーブルを操作・編集するための スキーマ定義(menu.schema.ts) の拡張について解説します。
前章で DepartmentMenu による部署単位の上書き構造を導入しましたが、
この章ではそれを踏まえ、テンプレート定義側(Menu)で守るべき制約や保護設定を型レベルで強化 します。

拡張の背景と目的

menu.schema.ts は、管理画面のメニュー編集やサイドバー変換処理など、
フロントエンド側で Menu データを扱うための共通スキーマ定義です。
ここに、部署ごとの上書きを禁止するフラグ lockHiddenOverride を追加しました。
このフラグはテンプレート側で「このメニューは非表示にできない」と明示的に設定できるもので、
前章の DepartmentMenu.hiddenOverride に対する「上書き禁止ルール」を提供します。

menuRecordSchema の変更点

ts
1// src/lib/sidebar/menu.schema.ts(menuRecordSchemaのみ抜粋) 2 3export const menuRecordSchema = z 4 .object({ 5 displayId: z.string().min(1), 6 parentId: z.string().nullable(), 7 order: z.number().int().nonnegative(), 8 title: z.string().min(1), 9 href: z 10 .string() 11 .regex(/^\/(?!.*\/$).*/, "先頭は /、末尾スラッシュは不可") 12 .optional(), 13 iconName: z.string().optional(), 14 match: z.enum(["exact", "prefix", "regex"]).default("prefix"), 15 pattern: z.string().optional(), 16 minPriority: z.number().int().positive().optional(), 17 isSection: z.boolean().default(false), 18 isActive: z.boolean(), 19 hidden: z.boolean().default(false), 20 lockHiddenOverride: z.boolean().default(false), 21 }) 22 .superRefine((val, ctx) => { 23 // 見出しノードのときはリンク関連を禁止 24 if (val.isSection) { 25 if (val.href) { 26 ctx.addIssue({ code: "custom", message: "セクションではhref不要です" }); 27 } 28 if (val.pattern) { 29 ctx.addIssue({ 30 code: "custom", 31 message: "セクションではpattern不要です", 32 }); 33 } 34 } 35 36 // regex指定時は pattern 必須 37 if (val.match === "regex" && !val.pattern) { 38 ctx.addIssue({ code: "custom", message: "regex指定時はpattern必須です" }); 39 } 40 41 // regex以外では pattern を禁止 42 if (val.match !== "regex" && val.pattern) { 43 ctx.addIssue({ code: "custom", message: "regex以外でpattern不要です" }); 44 } 45 }); 46 47export type MenuRecord = z.infer<typeof menuRecordSchema>;
menuRecordSchema では hidden に加えて、以下のような新しいプロパティを追加しています。
プロパティ既定値説明
lockHiddenOverridebooleanfalse部署単位の非表示上書きを禁止するフラグ
このプロパティにより、メニューの一覧や編集フォームで「非表示スイッチを無効化」できるようになります。
たとえば、/masters/menus ページそのものを隠すことを防ぐといったケースで有効です。
次の章では、このスキーマを活かして実際にメニュー情報を取得・合成する処理を実装します。

3. メニュー取得ロジックの新実装

この章では、新しく実装した メニュー取得処理(menu.fetch.ts の仕組みを解説します。
従来は固定データ(モック)でメニューを構築していましたが、
今回の実装では Prisma + Next.js のサーバーキャッシュ機能 を組み合わせ、
「部署単位のメニュー構成」をリアルタイムに合成できるようになりました。

新設ファイルの概要

ts
1// src/lib/sidebar/menu.fetch.ts 2import "server-only"; 3import { prisma } from "@/lib/database"; 4import type { MatchMode, MenuRecord } from "@/lib/sidebar/menu.schema"; 5import { unstable_cache as unstableCache, revalidateTag } from "next/cache"; 6 7/** 再検証タグ(部署ごと) */ 8export const menusTagFor = (departmentId: string) => 9 `menus:dept:${departmentId}`; 10 11/** 12 * 部署ごとの DepartmentMenu 上書きを合成した MenuRecord[] を返す。 13 * - 対象はテンプレート Menu.isActive = true のみ 14 * - 上書き可能: isEnabled(=isActiveに反映) / hidden / sortOrder(=orderに反映) 15 * - 親参照は displayId ベースに正規化して返却(既存UI互換) 16 * - 取得結果は部署タグでキャッシュされ、更新時は revalidateTag(menusTagFor(dept)) で破棄 17 */ 18export async function fetchMenusForDepartment( 19 departmentId: string, 20): Promise<MenuRecord[]> { 21 // 動的キー/タグを使うため、呼び出しごとに cached 関数を生成して即実行 22 const run = unstableCache( 23 async (): Promise<MenuRecord[]> => { 24 // ① 必要カラムのみ取得(親の displayId も同時取得) 25 const rows = await prisma.menu.findMany({ 26 where: { isActive: true }, // テンプレ有効のみが候補 27 select: { 28 id: true, 29 displayId: true, 30 parentId: true, 31 title: true, 32 href: true, 33 isExternal: true, // 将来用 34 iconName: true, 35 match: true, // Prisma enum: "exact" | "prefix" | "regex" 36 pattern: true, 37 minPriority: true, 38 isSection: true, 39 sortOrder: true, 40 remarks: true, 41 hidden: true, 42 lockHiddenOverride: true, 43 parent: { select: { displayId: true } }, // 親の displayId を取得 44 departmentMenus: { 45 where: { departmentId }, 46 select: { 47 isEnabled: true, 48 hiddenOverride: true, 49 sortOrder: true, 50 }, 51 take: 1, 52 }, 53 }, 54 orderBy: [ 55 { parentId: "asc" }, 56 { sortOrder: "asc" }, 57 { createdAt: "asc" }, 58 ], 59 }); 60 61 // ② 合成(null/未設定はテンプレ既定を採用) 62 const records: MenuRecord[] = rows.map((r) => { 63 const ov = r.departmentMenus[0]; 64 const effectiveIsActive: boolean = ov?.isEnabled ?? true; // 未設定=有効 65 const effectiveHidden: boolean = ov?.hiddenOverride ?? r.hidden; 66 const effectiveOrder: number = ov?.sortOrder ?? r.sortOrder; 67 68 const match: MatchMode = r.match as MatchMode; 69 70 return { 71 displayId: r.displayId, 72 parentId: r.parent?.displayId ?? null, // 親は displayId で返す(UI互換) 73 order: effectiveOrder, 74 title: r.title, 75 href: r.isSection ? undefined : (r.href ?? undefined), 76 iconName: r.iconName ?? undefined, 77 match, 78 pattern: r.pattern ?? undefined, 79 minPriority: r.minPriority ?? undefined, 80 isSection: r.isSection, 81 isActive: effectiveIsActive, 82 hidden: effectiveHidden, 83 lockHiddenOverride: r.lockHiddenOverride, 84 }; 85 }); 86 87 return records; 88 }, 89 // キャッシュキー(部署ごとに分離) 90 ["menus", "dept", departmentId], 91 // タグも部署ごと 92 { 93 tags: [menusTagFor(departmentId)], 94 revalidate: 95 process.env.NODE_ENV === "development" ? 30 : (false as 0 | false), 96 }, 97 ); 98 99 return run(); 100} 101 102/** 管理画面用:テンプレhidden=falseだけを対象に、部署上書きを合成して返す */ 103export async function fetchMenusForList( 104 departmentId: string, 105): Promise<MenuRecord[]> { 106 const run = unstableCache( 107 async (): Promise<MenuRecord[]> => { 108 const rows = await prisma.menu.findMany({ 109 where: { isActive: true, hidden: false }, // ★テンプレhiddenをDBで除外 110 select: { 111 id: true, 112 displayId: true, 113 parentId: true, 114 title: true, 115 href: true, 116 isExternal: true, 117 iconName: true, 118 match: true, 119 pattern: true, 120 minPriority: true, 121 isSection: true, 122 sortOrder: true, 123 remarks: true, 124 hidden: true, // ← ここは常に false(テンプレ値) 125 lockHiddenOverride: true, 126 parent: { select: { displayId: true } }, 127 departmentMenus: { 128 where: { departmentId }, 129 select: { 130 isEnabled: true, 131 hiddenOverride: true, 132 sortOrder: true, 133 }, 134 take: 1, 135 }, 136 }, 137 orderBy: [ 138 { parentId: "asc" }, 139 { sortOrder: "asc" }, 140 { createdAt: "asc" }, 141 ], 142 }); 143 144 return rows.map((r) => { 145 const ov = r.departmentMenus[0]; 146 const effectiveIsActive = ov?.isEnabled ?? true; 147 const effectiveHidden = ov?.hiddenOverride ?? false; // ← テンプレは常に false 148 const effectiveOrder = ov?.sortOrder ?? r.sortOrder; 149 150 return { 151 displayId: r.displayId, 152 parentId: r.parent?.displayId ?? null, 153 order: effectiveOrder, 154 title: r.title, 155 href: r.isSection ? undefined : (r.href ?? undefined), 156 iconName: r.iconName ?? undefined, 157 match: r.match as MatchMode, 158 pattern: r.pattern ?? undefined, 159 minPriority: r.minPriority ?? undefined, 160 isSection: r.isSection, 161 isActive: effectiveIsActive, 162 hidden: effectiveHidden, 163 lockHiddenOverride: r.lockHiddenOverride, 164 }; 165 }); 166 }, 167 ["menus:list", departmentId], 168 { 169 tags: [menusTagFor(departmentId)], 170 revalidate: 171 process.env.NODE_ENV === "development" ? 30 : (false as 0 | false), 172 }, 173 ); 174 return run(); 175} 176 177/** 変更系アクションから呼ぶ:該当部署のメニューキャッシュを破棄 */ 178export function revalidateMenusForDepartment(departmentId: string): void { 179 revalidateTag(menusTagFor(departmentId)); 180}
このファイルでは、以下の3つの責務を分離しています。
関数名役割使用箇所
fetchMenusForDepartment()部署別にメニューを合成して返す(Sidebar 用)/app/(protected)/layout.tsx
fetchMenusForList()管理画面の一覧用(テンプレ hidden=false のみ)/masters/menus/page.tsx
revalidateMenusForDepartment()キャッシュ破棄(更新アクション用)/app/_actions/menus/update.ts
Next.js の unstable_cache() を活用して、部署単位でキャッシュを分離
revalidateTag() により、部署単位でキャッシュを安全に破棄できる構成にしています。

fetchMenusForDepartment の設計

fetchMenusForDepartment() は、メインとなる取得関数です。
Menu テーブル(テンプレート)を基点に、部署側の DepartmentMenu の上書きを合成します。
txt
1Menu(テンプレ)───┬── hidden / order / isEnabled 2 │ ↑上書き可能 3DepartmentMenu──────┘
この関数では次のようなルールで合成が行われます。
属性上書き可否優先順位説明
isEnabledDepartmentMenu → Menu無効化設定(null なら有効扱い)
hiddenDepartmentMenu → Menu非表示上書き(部署単位で制御)
sortOrderDepartmentMenu → Menu並び順上書き
lockHiddenOverride🚫Menu 固有上書き禁止(テンプレ優先)
この設計により、テンプレの保護構造と部署の柔軟性を両立 しています。

unstable_cache の利用方法

unstable_cache() は Next.js のサーバーサイドキャッシュ API です。
ここでは「部署 ID ごとに異なるキャッシュ領域」を動的に作成し、
更新時にはタグ単位で再検証(revalidateTag)できるようにしています。
項目内容
キャッシュキー["menus", "dept", departmentId]
タグmenus:dept:${departmentId}
期限開発時のみ 30 秒、運用時は無期限
更新契機メニュー編集/部署設定変更アクションからの revalidateMenusForDepartment()
この構成により、部署Aのメニューを更新しても部署Bのキャッシュには影響しません。
スケール性と再現性の両面で有利な仕組みになっています。

fetchMenusForList の役割

管理画面(/masters/menus)では、テンプレート側で非表示となっているメニューは
編集対象外とするため、hidden=false のものだけを対象にしています。
これは UI上で「編集不可なシステムメニュー」を除外 する意図があります。
また、ここでも同様に部署単位で上書きが適用されるため、
部署別にメニュー順序や可視設定を確認できます。
条件意味
where: { isActive: true, hidden: false }有効なテンプレートかつ非表示でないもののみ取得
departmentMenus部署側の上書き設定を1件取得(take:1
orderBy親 → 並び順 → 登録日時の順で安定ソート
結果は MenuRecord[] に変換され、DataTable で扱いやすい形式で返されます。

キャッシュ再検証の仕組み

部署単位のメニューを更新した場合は、
revalidateMenusForDepartment(departmentId) を呼び出すことで
該当タグに紐づくキャッシュを即時破棄します。
txt
1[Update Action] 23revalidateMenusForDepartment("dept-uuid") 45再取得時に fetchMenusForDepartment() が再評価される
このように、 キャッシュの粒度を部署レベルに限定 しているため、
他の部署のメニュー構成に影響を与えることなく安全に再描画できます。

実装上のポイント(まとめ)

観点目的採用技術
データ合成テンプレート + 上書きの統合Prisma 多段 select/map
キャッシュ制御部署単位の再利用性向上Next.js unstable_cache
再検証更新時のみ対象部署を破棄revalidateTag
型安全性Zod による MenuRecord で整合TypeScript + Zod
この章では、メニュー構成を動的に組み立てる「取得レイヤ」を完成させました。
次の章では、このデータを実際の Sidebar コンポーネントへ渡し、
RBAC と統合して表示制御する仕組みを実装します。

4. Sidebar構成の更新 ─ layoutレベルでメニューを注入

この章では、メニュー取得処理をサイドバーへ統合するために
/app/(protected)/layout.tsx を改修し、部署ごとのメニューをサーバーレンダリングで注入 する構成へ変更した内容を解説します。
これにより、ログインユーザーの所属部署に応じた Sidebar を即時に生成できるようになりました。

修正後の構成

tsx
1// src/app/(protected)/layout.tsx 2import { AppSidebar } from "@/components/sidebar/app-sidebar"; 3import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; 4import { Toaster } from "@/components/ui/sonner"; 5import { AuthProviderServer } from "@/lib/auth/provider-server"; 6 7import { lookupSessionFromCookie } from "@/lib/auth/session"; 8import { prisma } from "@/lib/database"; 9import { fetchMenusForDepartment } from "@/lib/sidebar/menu.fetch"; 10import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 11 12export default function ProtectedLayout({ 13 children, 14}: { 15 children: React.ReactNode; 16}) { 17 return ( 18 <SidebarProvider> 19 {/* サーバ側で部署ID→メニュー取得(未ログイン時は空配列) */} 20 <SidebarWithMenus>{children}</SidebarWithMenus>{" "} 21 </SidebarProvider> 22 ); 23} 24 25// RSC 内でメニューを取得して AppSidebar に渡すラッパ 26async function SidebarWithMenus({ children }: { children: React.ReactNode }) { 27 let records: MenuRecord[] = []; 28 const session = await lookupSessionFromCookie(); 29 30 if (session.ok) { 31 // 部署IDだけ最小限に取得 32 const user = await prisma.user.findUnique({ 33 where: { id: session.userId }, 34 select: { departmentId: true }, 35 }); 36 if (user?.departmentId) { 37 records = await fetchMenusForDepartment(user.departmentId); 38 } 39 } 40 41 return ( 42 <AuthProviderServer> 43 <AppSidebar records={records} /> 44 <SidebarInset className="min-w-0"> 45 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */} 46 {children} 47 <Toaster richColors closeButton /> 48 </SidebarInset> 49 </AuthProviderServer> 50 ); 51}
今回の変更では、layout.tsx 内でメニューを取得する仕組みに刷新しました。
主な変更点は次のとおりです。
変更点内容効果
lookupSessionFromCookie() の導入現在のログインユーザーを特定SSR でも安全にセッションを扱える
prisma.user.findUnique() で部署ID取得ユーザーの所属部署を特定部署単位でメニューを分岐可能
fetchMenusForDepartment() の呼び出し部署単位で合成済みメニューを取得キャッシュ付きの効率的な取得
SidebarWithMenus の新設メニュー取得をRSCで完結SSR時点で Sidebar に records を注入
AppSidebar records={records} に変更明示的にメニューを注入クライアント側のモック依存を解消
このように データ取得責務を layout レベルへ引き上げた ことで、
全ページ共通の Sidebar に対して、ユーザーごとの部署設定を反映できるようになりました。

SidebarWithMenus コンポーネントの役割

新たに追加した SidebarWithMenus は、RSC(Server Component)として動作します。
これにより、サーバー上でメニューをフェッチしてから Sidebar を構築 でき、クライアント側での二段階ロードを防ぎます。
txt
1[SSRフロー] 2 3lookupSessionFromCookie() 45 prisma.user.findUnique() 67 fetchMenusForDepartment() 89 AppSidebar(records=部署別メニュー) 1011 Sidebar を含むページをSSRで描画
この仕組みを導入したことで、AppSidebar は純粋な表示専用コンポーネントになり、 UIとデータ取得の責務分離 が明確になりました。

修正の目的と効果(まとめ)

観点改善前改善後
メニュー取得クライアント側でモックデータサーバー側で Prisma + Cache による取得
部署対応非対応(全ユーザー共通)所属部署ごとに差し替え
初期描画空サイドバー → クライアント再描画SSR時点で完全描画
メンテナンス性AppSidebar 内に依存ロジックlayout.tsx に集約(責務分離)
この変更により、サイドバーの構築がより柔軟かつ安全になりました。
SSR 時点でメニューが確定するため、初回ロードも安定し、
認可(RBAC)と UI の整合性を自然に保てる構成になっています。
次の章では、この records を受け取る AppSidebar 側の更新内容を解説します。

5. AppSidebarの改修 ─ RBACフィルタ+hidden除外

この章では、Sidebar の UI コンポーネント AppSidebar において、
部署別メニューを受け取り、RBAC(権限優先度)フィルタhidden除外処理 を統合した改修内容を解説します。
これにより、認可ロジックとメニュー表示が完全に同期し、部署単位での動的な表示制御が可能になりました。

改修後の構成

tsx
1// src/components/sidebar/app-sidebar.tsx 2"use client"; 3 4import { useMemo } from "react"; 5 6import { ModeToggle } from "@/components/sidebar/mode-toggle"; 7import { NavMain } from "@/components/sidebar/nav-main"; 8import { NavUser } from "@/components/sidebar/nav-user"; 9import { NavTeam } from "@/components/sidebar/nav-team"; 10 11import { 12 Sidebar, 13 SidebarContent, 14 SidebarFooter, 15 SidebarHeader, 16 SidebarRail, 17} from "@/components/ui/sidebar"; 18 19import { mockTeam } from "@/lib/sidebar/mock-team"; 20import { toMenuTree } from "@/lib/sidebar/menu.transform"; 21 22import { filterMenuRecordsByPriority } from "@/lib/sidebar/menu.rbac"; 23import { useAuth } from "@/lib/auth/context"; 24import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 25 26type Props = React.ComponentProps<typeof Sidebar> & { 27 /** RSC(layout)から渡される部署別メニュー */ 28 records: MenuRecord[]; 29}; 30 31export function AppSidebar({ records, ...props }: Props) { 32 const { user } = useAuth(); // user?.rolePriority を使う 33 // 依存に使う“安定したプリミティブ”へ切り出し 34 const rolePriority = user?.rolePriority ?? 0; 35 36 const tree = useMemo(() => { 37 const filtered = filterMenuRecordsByPriority( 38 records.filter((r) => !r.hidden), // ← ここで非表示を除外 39 rolePriority, 40 ); 41 return toMenuTree(filtered); 42 }, [rolePriority, records]); 43 44 return ( 45 <Sidebar collapsible="icon" {...props}> 46 <SidebarHeader> 47 <NavTeam team={mockTeam} /> 48 </SidebarHeader> 49 50 <SidebarContent> 51 {/* priorityを利用したメニュー表示 */} 52 <nav aria-label="メインメニュー"> 53 <NavMain items={tree} /> 54 </nav> 55 </SidebarContent> 56 57 <SidebarFooter> 58 <ModeToggle className="ml-auto" /> 59 {/* Contextを利用したユーザ情報表示 */} 60 <NavUser /> 61 </SidebarFooter> 62 63 <SidebarRail /> 64 </Sidebar> 65 ); 66}
今回の改修では、Sidebar の責務を「表示専用」に限定しつつ、
サーバ側で取得したメニュー情報 (records) を クライアントで安全にフィルタリングして描画 するように整理しました。
主な変更点は次の通りです。
変更点修正内容効果
records の props 追加layout.tsx から部署別メニューを受け取るSSR時点でメニューが確定し、初回描画が安定
getMenus() の削除モック依存を排除し、実データを使用データソースが Prisma に統一
records.filter((r) => !r.hidden)非表示メニューを除外hidden状態が UI に反映される
filterMenuRecordsByPriority()ロール優先度による RBAC フィルタを適用権限外メニューを自動除外
rolePriority ?? 0未ログイン時の安全な初期値非ログイン状態でも例外を防止
これにより、クライアントレンダリング時には 部署別 + 権限別 + 非表示除外済み の状態でメニューが確定します。
サイドバーが表示される瞬間から、RBAC に基づいた正確なメニュー構成を反映できます。

RBAC フィルタ処理の流れ

txt
1AppSidebar 23records(部署別メニュー) 4 ↓ filter(r => !r.hidden) 5 ↓ filterMenuRecordsByPriority(records, rolePriority) 6 ↓ toMenuTree(filtered) 7 ↓ NavMain(items=tree)
filterMenuRecordsByPriority() は、ユーザーの rolePriority に基づいて
アクセス可能なしきい値(minPriority)を下回るメニューを除外します。
さらに、hidden フラグ付きの項目はあらかじめ取り除かれているため、
実際のサイドバーには「可視メニューのみ」が確実に表示されます。

型定義とProps構成

AppSidebar は以下のように型を拡張しています。
項目説明
recordsMenuRecord[]layout側から渡される部署別メニュー
propsSidebarの既存propscollapsible設定やクラス名など
この records プロパティは Server Component から直接注入される 点がポイントで、
SSRとCSRの境界をまたいだ一貫したメニュー表示が可能になりました。

責務分離の明確化(まとめ)

観点旧構成新構成
データ取得AppSidebar 内でモック呼び出しlayout.tsx 側(RSC)で取得・注入
権限制御部分的(モック依存)RBACに完全対応
hidden対応未実装.filter((r) => !r.hidden) で除外
表示責務データ取得と混在表示専用に一本化
これにより、Sidebar は UI レイヤーとしての純粋性を取り戻し、
サーバ側のメニューキャッシュ機構 (fetchMenusForDepartment) と
RBAC 判定 (filterMenuRecordsByPriority) の双方が連携する堅牢な構成となりました。
次の章では、これらの Sidebar 構成を支える Guard 判定側(guard.ssr.ts)の更新内容を解説します。

6. Guard処理の刷新 ─ DBメニューに基づく認可判定

この章では、ページアクセスの認可判定を司る Guard 処理を刷新し、
DB上のメニュー構成(Menu + DepartmentMenu)に基づくロジック へと移行した内容を解説します。
これにより、画面遷移の認可判定と Sidebar 表示が完全に一致する構成となります。

改修後の構成

ts
1// src/lib/auth/guard.core.ts 2import type { 3 AuthUserSnapshot, 4 GuardDecision, 5 GuardOptions, 6} from "@/lib/auth/types"; 7import { buildMenuIndex, enumerateAncestorHrefs } from "./guard.util"; 8import { pickBestMatch } from "./guard.matcher"; 9import { computeRequiredPriority } from "./guard.priority"; 10import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 11// ★ DBメニュー取得(部署別の合成済み MenuRecord[] を返す) 12import { fetchMenusForDepartment } from "@/lib/sidebar/menu.fetch"; 13 14/** 内部共通:与えられた records を使って判定(純粋関数) */ 15function decideWithRecords( 16 href: string, 17 user: AuthUserSnapshot | null, 18 records: MenuRecord[], 19 options: GuardOptions, 20): GuardDecision { 21 if (!user) return { ok: false, reason: "UNAUTHORIZED" }; 22 23 const { byId } = buildMenuIndex(records); 24 const best = pickBestMatch(records, href); 25 26 if (!best) { 27 if (options.strictNotFound) return { ok: false, reason: "NOT_FOUND" }; 28 // 将来拡張(現状は strict 想定) 29 for (const a of enumerateAncestorHrefs(href).slice(1)) { 30 const b = pickBestMatch(records, a); 31 if (b) { 32 const { required } = computeRequiredPriority(b, byId); 33 return user.rolePriority >= required 34 ? { ok: true, requiredPriority: required, matchedId: b.displayId } 35 : { ok: false, reason: "FORBIDDEN" }; 36 } 37 } 38 return { ok: false, reason: "NOT_FOUND" }; 39 } 40 41 const { required } = computeRequiredPriority(best, byId); 42 if (user.rolePriority >= required) { 43 return { ok: true, requiredPriority: required, matchedId: best.displayId }; 44 } 45 return { ok: false, reason: "FORBIDDEN" }; 46} 47 48export function decideGuard( 49 href: string, 50 user: AuthUserSnapshot | null, 51 options: GuardOptions = { strictNotFound: true }, 52): GuardDecision { 53 // 旧:INITIAL_MENU_RECORDS を使っていたが、モックは廃止方向。 54 // 互換のため空配列で NOT_FOUND を返すだけの形に退避させても良いが、 55 // 既存呼び出しは guardHrefOrRedirect 側で置換するため未使用想定。 56 return decideWithRecords(href, user, [], options); 57} 58 59/** 60 * ガード判定のエントリポイント 61 * @param href ページの絶対パス(例: "/users/new") 62 * @param user 認証済みユーザ(未ログインなら null) 63 * @param options B案(未定義は拒否)を strict に適用するか 64 */ 65/** ★ 新規(本命):部署別DBメニューで判定 */ 66export async function decideGuardAsync( 67 href: string, 68 user: AuthUserSnapshot | null, 69 departmentId: string, 70 options: GuardOptions = { strictNotFound: true }, 71): Promise<GuardDecision> { 72 if (!user) return { ok: false, reason: "UNAUTHORIZED" }; 73 const records = await fetchMenusForDepartment(departmentId); 74 return decideWithRecords(href, user, records, options); 75}
ts
1// src/lib/auth/guard.ssr.ts 2import { redirect } from "next/navigation"; 3import { lookupSessionFromCookie } from "@/lib/auth/session"; 4import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 5// ★ DB版の判定に切替 6import { decideGuardAsync } from "./guard.core"; 7import { prisma } from "@/lib/database"; 8 9/** 成功時は UserSnapshot を返し、失敗時は redirect/エラーで終了 */ 10export async function guardHrefOrRedirect(href: string, loginPath = "/") { 11 const session = await lookupSessionFromCookie(); 12 if (!session.ok) { 13 redirect(loginPath); // 401相当 14 } 15 16 const user = await getUserSnapshot(session.userId); 17 if (!user) { 18 redirect(loginPath); 19 } 20 21 // ★ 最小差分:departmentId を軽量SELECT(後で snapshot に含めて削除予定) 22 const u = await prisma.user.findUnique({ 23 where: { id: session.userId }, 24 select: { departmentId: true }, 25 }); 26 const departmentId = u?.departmentId ?? ""; 27 28 const decision = await decideGuardAsync(href, user, departmentId, { 29 strictNotFound: true, 30 }); 31 32 if (!decision.ok) { 33 if (decision.reason === "UNAUTHORIZED") redirect(loginPath); 34 if (decision.reason === "FORBIDDEN") redirect("/403"); 35 if (decision.reason === "NOT_FOUND") redirect("/404"); 36 } 37 38 return user; // page.tsx から機能フラグ等も参照可能 39}
従来のガード判定では、モックデータ INITIAL_MENU_RECORDS をもとに
アクセス制御を行っていましたが、実際のメニュー情報との乖離が課題でした。
今回の改修では、以下の2点を中心に設計を見直しています。
変更点内容効果
DB連携化fetchMenusForDepartment() を利用して部署別メニューを取得実際のSidebar構成と完全同期
非同期化decideGuardAsync() を新設Prisma経由のDBアクセスに対応
SSR統合guardHrefOrRedirect() がDBベースの判定に移行サーバーサイドで認可結果を即時反映
フォールバック削除モック版 INITIAL_MENU_RECORDS の参照を廃止開発用データ依存を解消

decideGuardAsync の新設

新設された decideGuardAsync() は、
指定された部署IDに基づき、fetchMenusForDepartment() を呼び出して
DB上の最新メニュー構成を取得し、ページURLごとに RBAC 判定を行います。
txt
1decideGuardAsync() 23fetchMenusForDepartment(departmentId) 45buildMenuIndex() + pickBestMatch() 67computeRequiredPriority() 89ユーザーの rolePriority と比較 → 判定結果を返す
従来の decideGuard() はモックデータ専用だったため、
残しつつも「空配列を返す退避用」として実質的に非推奨化されています。
これにより、現実のメニュー構成を唯一の認可基盤とする設計 が成立しました。

guardHrefOrRedirect の刷新

サーバーサイドでの実行ポイント guardHrefOrRedirect() も、
DB版の decideGuardAsync() を呼び出す構成に切り替えています。
これにより、ページアクセス時に部署別メニューのしきい値が正しく適用されます。
処理ステップ内容備考
lookupSessionFromCookie()セッション検証認証がない場合は即リダイレクト
getUserSnapshot()ユーザー情報を取得RBAC判定に必要なrolePriorityを保持
prisma.user.findUnique()部署IDのみ軽量取得snapshot拡張の暫定措置
decideGuardAsync()DBメニューに基づく判定RBAC優先度とminPriorityを比較
リダイレクト制御/403 /404 / に振り分け権限・存在・未認証に応じて遷移
この処理フローにより、Next.js の SSR 段階で
「ログイン済み+部署権限が有効」なユーザーのみがページへ進めるようになっています。
特に /masters 系などの管理ページでは、
部署に紐づくロール権限を基準とした厳密な制御が可能です。

decideWithRecords の抽象化

decideWithRecords() は、メニュー配列(MenuRecord[])を入力として
共通の認可ロジックを適用する純粋関数です。
DB版・モック版どちらのガードも、この関数を基盤に動作します。
処理内容目的
pickBestMatch()現在URLに最も近いメニュー項目を特定
computeRequiredPriority()対応メニューの最小しきい値を算出
user.rolePriority 比較権限を満たしているか判定
結果の返却ok / reason / matchedId を統一出力
このように関数分離することで、
メニュー構成の差異に依存しない共通判定基盤 が実現され、
今後の拡張(Server Action 連携・API判定統一など)にも対応しやすくなっています。

改修効果のまとめ

観点旧構成新構成
データソースモック(INITIAL_MENU_RECORDSDB(Menu + DepartmentMenu
認可基準静的部署単位で動的
判定処理同期関数非同期(Prisma対応)
UI同期Sidebar と乖離Sidebar と完全一致
再利用性ページ限定SSR / API / Action に拡張可能
この章で、ガード処理は DBメニューを唯一の信頼ソース(Single Source of Truth) として再構築されました。
これにより、Sidebar・Guard・RBAC の3要素が統一基盤で連携し、
DELOGs の管理画面全体が一貫した権限制御の下で動作するようになりました。

7. /masters/menus ページのリニューアル

この章では、部署ごとのメニュー可視制御と並び順変更に対応するため、
/masters/menus ページ全体を DB連携ベースの管理UI に刷新した内容を解説します。
対象ファイルは次の4つです:
  • src/types/table-meta.d.ts
  • src/app/(protected)/masters/menus/page.tsx
  • src/app/(protected)/masters/menus/columns.tsx
  • src/app/(protected)/masters/menus/data-table.tsx

table-meta.d.ts:メタ情報の拡張

ts
1// src/types/table-meta.d.ts 2import "@tanstack/table-core"; 3import type { ReqStatus } from "@/app/(protected)/users/email-change-requests/status-multi-select"; 4 5declare module "@tanstack/table-core" { 6 interface TableMeta<TData extends RowData> { 7 onMoveUp?: (id: string, _row?: TData) => void; 8 onMoveDown?: (id: string, _row?: TData) => void; 9 onToggleActive?: (displayId: string, next: boolean, _row?: TData) => void; 10 // ★ 追加:処理中IDセット 11 movingIds?: Set<string>; 12 togglingIds?: Set<string>; 13 // ★ 追加:この行は「最終的にこうなるはず」という可視状態 14 pendingVisible?: Map<string, boolean>; 15 16 /** 依頼を「再発行済み」にする */ 17 onIssue?: (id: string, _row?: TData) => void | Promise<void>; 18 /** 依頼を「拒否」にする */ 19 onReject?: (id: string, _row?: TData) => void | Promise<void>; 20 /** メール変更申請を「承認」する */ 21 onApprove?: (id: string, _row?: TData) => void | Promise<void>; 22 // ▼ ユーザ一覧用(必要なものだけ) 23 roleOptions?: Array<{ value: string; label: string }>; 24 roles?: string[]; 25 setRoles?: (next: string[]) => void; 26 27 status?: "ALL" | "ACTIVE" | "INACTIVE"; 28 setStatus?: (next: "ALL" | "ACTIVE" | "INACTIVE") => void; 29 30 createdRange?: import("react-day-picker").DateRange | undefined; 31 setCreatedRange?: ( 32 r: import("react-day-picker").DateRange | undefined, 33 ) => void; 34 35 updatedRange?: import("react-day-picker").DateRange | undefined; 36 setUpdatedRange?: ( 37 r: import("react-day-picker").DateRange | undefined, 38 ) => void; 39 40 // ▼ ロール一覧用(masters/roles) 41 kindOptions?: Array<{ value: string; label: string }>; 42 kinds?: string[]; 43 setKinds?: (next: string[]) => void; 44 45 // メール変更申請一覧用 46 // 状態(複数選択) 47 statusOptions?: Array<{ value: ReqStatus; label: string }>; 48 statuses?: ReqStatus[]; 49 setStatuses?: (next: ReqStatus[]) => void; 50 51 // 日付レンジ(申請日時・処理日時) 52 requestedRange?: import("react-day-picker").DateRange | undefined; 53 setRequestedRange?: ( 54 r: import("react-day-picker").DateRange | undefined, 55 ) => void; 56 57 processedRange?: import("react-day-picker").DateRange | undefined; 58 setProcessedRange?: ( 59 r: import("react-day-picker").DateRange | undefined, 60 ) => void; 61 } 62} 63 64export {};
TableMeta に行単位の処理状態を管理するためのフィールドを追加しました。
特に、メニュー一覧では「順序変更」や「可視切り替え」などの非同期アクションが発生するため、
これらの操作を一時的にフラグ化してUIへ反映できるようにしています。
新規項目役割
onToggleActive(displayId, next, _row)可視/非表示トグル
movingIdsSet<string>並び替え中の行ID集合
togglingIdsSet<string>可視切り替え中の行ID集合
pendingVisibleMap<string, boolean>トグル完了前に「最終状態」を保持
これにより、列コンポーネント側(columns.tsx)で処理中アイコンを自然に描画でき、
非同期更新時のチラつきや瞬間的な状態戻りを防げるようになりました。

page.tsx:部署別データのSSR取得

tsx
1// src/app/(protected)/masters/menus/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 { fetchMenusForList } from "@/lib/sidebar/menu.fetch"; 16import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 17import DataTable from "./data-table"; 18import { columns } from "./columns"; 19 20export const metadata: Metadata = { 21 title: "メニュー一覧", 22 description: "部署ごとのメニュー可視・順序の上書き(DB連携)。", 23}; 24 25export default async function Page() { 26 const viewer = await guardHrefOrRedirect("/masters/menus", "/"); 27 28 // 自分の部署だけ取得 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 // 部署の「合成済み」メニュー(hidden=含む・isActive=上書き反映) 36 const menus = await fetchMenusForList(me.departmentId); 37 38 // ツリー順(階層順)に初期整列 39 const hier = orderHierarchically(menus); 40 41 return ( 42 <> 43 <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"> 44 <div className="flex items-center gap-2 px-4"> 45 <SidebarTrigger className="-ml-1" /> 46 <Separator 47 orientation="vertical" 48 className="mr-2 data-[orientation=vertical]:h-4" 49 /> 50 <Breadcrumb> 51 <BreadcrumbList> 52 <BreadcrumbItem className="hidden md:block"> 53 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 54 </BreadcrumbItem> 55 <BreadcrumbSeparator className="hidden md:block" /> 56 <BreadcrumbItem> 57 <BreadcrumbPage>メニュー一覧</BreadcrumbPage> 58 </BreadcrumbItem> 59 </BreadcrumbList> 60 </Breadcrumb> 61 </div> 62 </header> 63 64 <div className="w-full max-w-[1729px] p-4 pt-0"> 65 <DataTable 66 columns={columns} 67 data={hier} 68 canDownloadData={viewer.canDownloadData} 69 canEditData={viewer.canEditData} 70 /> 71 </div> 72 </> 73 ); 74} 75 76/** 親→子→孫の順で並べ直し(表示は常に階層順) */ 77function orderHierarchically(list: MenuRecord[]): MenuRecord[] { 78 const byParent = new Map<string | null, MenuRecord[]>(); 79 for (const r of list) { 80 const key = r.parentId ?? null; 81 const arr = byParent.get(key) ?? []; 82 arr.push(r); 83 byParent.set(key, arr); 84 } 85 for (const [, arr] of byParent) arr.sort((a, b) => a.order - b.order); 86 87 const out: MenuRecord[] = []; 88 const walk = (parentId: string | null) => { 89 const children = byParent.get(parentId) ?? []; 90 for (const c of children) { 91 out.push(c); 92 walk(c.displayId); 93 } 94 }; 95 walk(null); 96 return out; 97}
ページコンポーネントでは、これまでモックデータを使っていた処理を廃止し、
fetchMenusForList() によって 部署別・DB由来の合成メニュー を取得するように変更しました。
修正点内容効果
guardHrefOrRedirect() 呼び出しSSRでの認可確認を統合非ログイン・権限外を即リダイレクト
prisma.user.findUnique()departmentId を取得所属部署単位でメニュー構築
fetchMenusForList()DBから部署上書き済みメニューを取得hidden/並び順/isActiveを合成
orderHierarchically()親→子→孫の階層順で整列表示順を安定化
これにより、ページ描画時点で
「部署別 + 並び順済み + 上書き済み」の最新データを取得できるようになりました。

columns.tsx:操作列・トグル列の刷新

tsx
1// src/app/(protected)/masters/menus/columns.tsx 2"use client"; 3import type { ColumnDef, HeaderContext } from "@tanstack/react-table"; 4import { 5 ArrowDown, 6 ArrowUp, 7 SlidersVertical, 8 LoaderCircle, 9} from "lucide-react"; 10import { Badge } from "@/components/ui/badge"; 11import { Button } from "@/components/ui/button"; 12import { 13 Tooltip, 14 TooltipContent, 15 TooltipTrigger, 16} from "@/components/ui/tooltip"; 17import type { MenuRecord, MatchMode } from "@/lib/sidebar/menu.schema"; 18import { Switch } from "@/components/ui/switch"; 19import { SortButton } from "@/components/datagrid/sort-button"; 20import { 21 Popover, 22 PopoverContent, 23 PopoverTrigger, 24} from "@/components/ui/popover"; 25import * as PopoverPrimitive from "@radix-ui/react-popover"; 26import { StatusFilter } from "@/components/filters/status-filter"; 27 28function HeaderWithSort<TData, TValue>({ 29 title, 30 ctx, 31}: { 32 title: string; 33 ctx: HeaderContext<TData, TValue>; 34}) { 35 return ( 36 <div className="flex items-center gap-1"> 37 <span className="whitespace-nowrap">{title}</span> 38 <SortButton 39 column={ctx.column} 40 aria-label={`${title}でソート`} 41 title={`${title}でソート`} 42 /> 43 </div> 44 ); 45} 46 47function IndentedTitle({ title, depth }: { title: string; depth: number }) { 48 const cls = depth === 0 ? "" : depth === 1 ? "pl-4" : "pl-10"; 49 return <div className={cls}>{title}</div>; 50} 51function MatchBadge({ mode }: { mode: MatchMode | undefined }) { 52 const m = mode ?? "prefix"; 53 if (m === "exact") return <Badge variant="secondary">exact</Badge>; 54 if (m === "regex") return <Badge variant="outline">regex</Badge>; 55 return <Badge>prefix</Badge>; 56} 57 58export type RowShape = MenuRecord & { 59 depth: number; 60 canUp: boolean; 61 canDown: boolean; 62 effMinPriority?: number; // ★ 追加:継承適用後 63}; 64 65export const columns: ColumnDef<RowShape>[] = [ 66 { 67 accessorKey: "displayId", 68 header: (ctx) => <HeaderWithSort title="表示ID" ctx={ctx} />, 69 size: 86, 70 enableResizing: false, 71 cell: ({ row }) => ( 72 <span className="font-mono">{row.original.displayId}</span> 73 ), 74 }, 75 { 76 accessorKey: "title", 77 header: (ctx) => <HeaderWithSort title="タイトル" ctx={ctx} />, 78 cell: ({ row }) => ( 79 <IndentedTitle title={row.original.title} depth={row.original.depth} /> 80 ), 81 }, 82 { 83 accessorKey: "href", 84 header: (ctx) => <HeaderWithSort title="Path" ctx={ctx} />, 85 cell: ({ row }) => 86 row.original.isSection ? ( 87 <span className="text-muted-foreground"></span> 88 ) : ( 89 <span className="font-mono">{row.original.href}</span> 90 ), 91 }, 92 { 93 id: "match", 94 header: (ctx) => <HeaderWithSort title="一致" ctx={ctx} />, 95 size: 80, 96 enableResizing: false, 97 cell: ({ row }) => 98 row.original.isSection ? ( 99 <span className="text-muted-foreground"></span> 100 ) : ( 101 <MatchBadge mode={row.original.match} /> 102 ), 103 }, 104 { 105 accessorKey: "minPriority", 106 header: (ctx) => <HeaderWithSort title="しきい値" ctx={ctx} />, 107 size: 80, 108 enableResizing: false, 109 accessorFn: (row) => row.effMinPriority ?? -1, 110 cell: ({ row }) => { 111 const v = row.original.effMinPriority; 112 return v === undefined ? ( 113 <span className="text-muted-foreground">(全員)</span> 114 ) : ( 115 <span className="font-mono tabular-nums">{v}</span> 116 ); 117 }, 118 }, 119 { 120 id: "order", 121 header: (ctx) => <HeaderWithSort title="順序" ctx={ctx} />, 122 size: 96, 123 enableResizing: false, 124 enableSorting: true, 125 cell: ({ row, table }) => { 126 const { canUp, canDown } = row.original; 127 const onUp = table.options.meta?.onMoveUp; 128 const onDown = table.options.meta?.onMoveDown; 129 // ★ 処理中? 130 const moving = table.options.meta?.movingIds?.has(row.original.displayId); 131 if (moving) { 132 return ( 133 <div className="flex h-8 items-center justify-start"> 134 <LoaderCircle className="text-muted-foreground h-4 w-4 animate-spin" /> 135 </div> 136 ); 137 } 138 return ( 139 <div className="flex gap-1"> 140 <Tooltip> 141 <TooltipTrigger asChild> 142 <Button 143 size="icon" 144 variant="outline" 145 className="size-8 cursor-pointer" 146 onClick={() => onUp?.(row.original.displayId, row.original)} 147 disabled={!canUp} 148 aria-label="ひとつ上へ" 149 > 150 <ArrowUp /> 151 </Button> 152 </TooltipTrigger> 153 <TooltipContent>ひとつ上へ</TooltipContent> 154 </Tooltip> 155 <Tooltip> 156 <TooltipTrigger asChild> 157 <Button 158 size="icon" 159 variant="outline" 160 className="size-8 cursor-pointer" 161 onClick={() => onDown?.(row.original.displayId, row.original)} 162 disabled={!canDown} 163 aria-label="ひとつ下へ" 164 > 165 <ArrowDown /> 166 </Button> 167 </TooltipTrigger> 168 <TooltipContent>ひとつ下へ</TooltipContent> 169 </Tooltip> 170 </div> 171 ); 172 }, 173 }, 174 { 175 id: "visible", 176 // 可視 = !hidden をフィルタ&表示 177 header: (ctx) => { 178 const status = ctx.table.options.meta?.status ?? "ALL"; 179 const setStatus = ctx.table.options.meta?.setStatus ?? (() => {}); 180 const active = status !== "ALL"; 181 return ( 182 <div className="flex items-center gap-1"> 183 <span className="whitespace-nowrap">可視</span> 184 <Popover> 185 <PopoverTrigger asChild> 186 <Button 187 type="button" 188 size="icon" 189 variant={active ? "default" : "outline"} 190 className="h-7 w-7 cursor-pointer" 191 aria-label="可視のフィルタ" 192 title="可視のフィルタ" 193 > 194 <SlidersVertical className="h-3.5 w-3.5" /> 195 </Button> 196 </PopoverTrigger> 197 <PopoverContent align="end" className="w-[220px] p-0"> 198 <StatusFilter 199 value={status} 200 onChange={setStatus} 201 labels={{ 202 all: "すべて", 203 active: "可視のみ", 204 inactive: "非表示のみ", 205 }} 206 footer={ 207 <PopoverPrimitive.Close asChild> 208 <Button 209 variant="ghost" 210 size="sm" 211 type="button" 212 className="cursor-pointer" 213 > 214 閉じる 215 </Button> 216 </PopoverPrimitive.Close> 217 } 218 /> 219 </PopoverContent> 220 </Popover> 221 </div> 222 ); 223 }, 224 size: 70, 225 enableResizing: false, 226 enableSorting: true, 227 cell: ({ row, table }) => { 228 const onToggle = table.options.meta?.onToggleActive; // シグネチャは流用 229 const visible = !row.original.hidden; 230 // ★ ここで保護チェック 231 const protectedRow = !!row.original.lockHiddenOverride; 232 // ★ 期待状態があるならそれを優先表示(旧→新のチラつきを防ぐ) 233 const pv = table.options.meta?.pendingVisible?.get( 234 row.original.displayId, 235 ); 236 const displayVisible = typeof pv === "boolean" ? pv : visible; 237 // ★ 処理中? 238 const toggling = table.options.meta?.togglingIds?.has( 239 row.original.displayId, 240 ); 241 // 保護対象なら常に disabled、Tooltip で理由を表示 242 if (protectedRow) { 243 return ( 244 <Tooltip> 245 <TooltipTrigger asChild> 246 <div className="opacity-60"> 247 <Switch 248 checked={displayVisible} 249 disabled 250 aria-label="可視/非表示" 251 /> 252 </div> 253 </TooltipTrigger> 254 <TooltipContent>このページからは非表示にできません</TooltipContent> 255 </Tooltip> 256 ); 257 } 258 if (toggling) { 259 // スイッチ自体は期待状態で描画しつつ、半透明+上にクルクルでもOK 260 // 単純にスピナーだけでも可。ここでは“スイッチを無効化+期待状態”にして自然に。 261 return ( 262 <div className="relative flex items-center justify-start"> 263 <Switch 264 checked={displayVisible} 265 disabled 266 aria-label="可視/非表示" 267 /> 268 <div className="absolute"> 269 <LoaderCircle className="text-muted-foreground h-4 w-4 animate-spin" /> 270 </div> 271 </div> 272 ); 273 } 274 return ( 275 <Switch 276 checked={visible} 277 onCheckedChange={(next) => 278 onToggle?.(row.original.displayId, next, row.original) 279 } 280 className="cursor-pointer" 281 aria-label="可視/非表示" 282 /> 283 ); 284 }, 285 // filter: StatusFilter と合わせる(可視/非表示) 286 filterFn: (row, _id, value: "ALL" | "ACTIVE" | "INACTIVE") => 287 value === "ALL" 288 ? true 289 : value === "ACTIVE" 290 ? !row.original.hidden 291 : row.original.hidden, 292 }, 293 // 検索用 hidden 列 294 { 295 id: "q", 296 accessorFn: (r) => 297 `${r.displayId} ${r.title} ${r.href ?? ""}`.toLowerCase(), 298 enableHiding: true, 299 enableSorting: false, 300 enableResizing: false, 301 size: 0, 302 header: () => null, 303 cell: () => null, 304 }, 305];
列定義ファイルでは、可視切り替えトグル・順序変更ボタンの両方に 非同期処理中の視覚フィードバック を追加しました。
また、誤操作防止のため「lockHiddenOverride」がtrueの行はスイッチを常に無効化するようにしています。
対応箇所内容改善点
順序列<LoaderCircle /> を挿入並べ替え中のスピナー表示
可視列pendingVisible / togglingIds を反映切替中のチラつきを防止
lockHiddenOverrideTooltip付きでトグル禁止誤って非表示にできない設計
StatusFilterPopover+Filter統合可視・非表示の切替UIを統一
特に「このページから非表示にできません」というTooltipメッセージは lockHiddenOverrideの設定に基づいて動的に付与されるため、
重要メニュー(自身の一覧など)を誤って隠すことができません。

data-table.tsx:Datagrid構造への完全統合

tsx
1// src/app/(protected)/masters/menus/data-table.tsx 2"use client"; 3 4import * as React from "react"; 5import type { 6 ColumnDef, 7 VisibilityState, 8 SortingState, 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 { MenuRecord } from "@/lib/sidebar/menu.schema"; 26import type { RowShape } from "./columns"; 27import { DatagridToolbar } from "@/components/datagrid/datagrid-toolbar"; 28import { DatagridSummary } from "@/components/datagrid/datagrid-summary"; 29import { DatagridPagination } from "@/components/datagrid/datagrid-pagination"; 30import { useDatagridQueryState } from "@/lib/datagrid/use-datagrid-query-state"; 31import { usePersistentDatagridState } from "@/lib/datagrid/use-persistent-datagrid-state"; 32import { buildCsv, downloadCsv } from "@/lib/datagrid/csv"; 33import { format } from "date-fns"; 34import { ja } from "date-fns/locale"; 35import { useRouter } from "next/navigation"; 36 37// サーバーアクション 38import { 39 moveMenuOrderAction, 40 toggleMenuHiddenAction, 41} from "@/app/_actions/menus/update"; 42 43// 既存ユーティリティ再利用 44function calcDepthMap(rows: MenuRecord[]): Map<string, number> { 45 const parentById = new Map<string, string | null>(); 46 rows.forEach((r) => parentById.set(r.displayId, r.parentId)); 47 const depthById = new Map<string, number>(); 48 const depthOf = (id: string | null | undefined): number => { 49 if (!id) return 0; 50 if (depthById.has(id)) return depthById.get(id)!; 51 const p = parentById.get(id); 52 const d = p ? 1 + depthOf(p) : 0; 53 depthById.set(id, d); 54 return d; 55 }; 56 rows.forEach((r) => depthOf(r.displayId)); 57 return depthById; 58} 59 60function withMoveFlags<T extends MenuRecord>(rows: T[]) { 61 const byParent = new Map<string | null, MenuRecord[]>(); 62 rows.forEach((r) => { 63 const key = r.parentId ?? null; 64 const arr = byParent.get(key) ?? []; 65 arr.push(r); 66 byParent.set(key, arr); 67 }); 68 const flags = new Map<string, { canUp: boolean; canDown: boolean }>(); 69 for (const [, arr] of byParent) { 70 arr 71 .slice() 72 .sort((a, b) => a.order - b.order) 73 .forEach((r, i, list) => { 74 flags.set(r.displayId, { canUp: i > 0, canDown: i < list.length - 1 }); 75 }); 76 } 77 return rows.map((r) => ({ 78 ...r, 79 ...(flags.get(r.displayId) ?? { canUp: false, canDown: false }), 80 })) as Array<T & { canUp: boolean; canDown: boolean }>; 81} 82 83// しきい値の継承を付与(自分→親→祖父... の順で最初に見つかった値) 84function attachEffectiveMinPriority<T extends MenuRecord>(rows: T[]) { 85 const byId = new Map(rows.map((r) => [r.displayId, r])); 86 const memo = new Map<string, number | undefined>(); 87 88 const getEff = (id: string): number | undefined => { 89 if (memo.has(id)) return memo.get(id); 90 const self = byId.get(id); 91 if (!self) return undefined; 92 const v = 93 self.minPriority ?? (self.parentId ? getEff(self.parentId) : undefined); 94 memo.set(id, v); 95 return v; 96 }; 97 98 return rows.map((r) => ({ 99 ...r, 100 effMinPriority: getEff(r.displayId), 101 })) as Array<T & { effMinPriority?: number }>; 102} 103 104type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE"; 105 106type Props = { 107 columns: ColumnDef<RowShape, unknown>[]; 108 data: MenuRecord[]; // SSR整列済み 109 canDownloadData?: boolean; 110 canEditData?: boolean; 111}; 112 113export default function DataTable({ 114 columns, 115 data, 116 canDownloadData = false, 117 canEditData = false, 118}: Props) { 119 const router = useRouter(); 120 const [mounted, setMounted] = React.useState(false); 121 React.useEffect(() => setMounted(true), []); 122 // ★ 行ごとの処理中管理 123 // ★ pending: 「この行は最終的に 'visible = X' になるはず」を覚えておく 124 const [togglingIds, setTogglingIds] = React.useState<Set<string>>(new Set()); 125 const [pendingVisible, setPendingVisible] = React.useState< 126 Map<string, boolean> 127 >(new Map()); 128 // ★ 並び替えの処理中(行単位) 129 const [movingIds, setMovingIds] = React.useState<Set<string>>(new Set()); 130 131 // クライアント状態(検索/フィルタ) 132 const [queryState, setQueryState] = useDatagridQueryState( 133 "menus", 134 { 135 q: "", 136 status: "ALL" as StatusFilter, 137 cols: [ 138 "displayId", 139 "title", 140 "href", 141 "minPriority", 142 "order", 143 "visible", 144 ] as string[], 145 }, 146 { persistKey: "menus" }, 147 ); 148 149 const setQ = (v: string) => setQueryState((s) => ({ ...s, q: v })); 150 const setStatus = (next: StatusFilter) => 151 setQueryState((s) => ({ ...s, status: next })); 152 const setVisibleColumnIds = (ids: string[]) => 153 setQueryState((s) => ({ ...s, cols: ids })); 154 155 // 可視列(初回SSRは全列表示に合わせる) 156 const allColumnIds = React.useMemo( 157 () => 158 [ 159 "displayId", 160 "title", 161 "href", 162 "match", 163 "minPriority", 164 "order", 165 "visible", 166 ] as const, 167 [], 168 ); 169 type ColId = (typeof allColumnIds)[number]; 170 const [persisted, setPersisted] = usePersistentDatagridState("menus", { 171 pageSize: 20, 172 }); 173 const [sorting, setSorting] = React.useState<SortingState>([ 174 { id: "order", desc: false }, 175 ]); 176 177 // 可視列 178 const effectiveVisibleColumnIds: ColId[] = mounted 179 ? (queryState.cols as ColId[]) 180 : (Array.from(allColumnIds) as ColId[]); 181 const columnVisibility = React.useMemo<VisibilityState>(() => { 182 const set = new Set(effectiveVisibleColumnIds); 183 return { 184 q: false, 185 actions: false, 186 displayId: set.has("displayId"), 187 title: set.has("title"), 188 href: set.has("href"), 189 match: set.has("match"), 190 minPriority: set.has("minPriority"), 191 order: set.has("order"), 192 visible: set.has("visible"), 193 }; 194 }, [effectiveVisibleColumnIds]); 195 196 // 表示行(depth と ↑↓ 可否を付与) 197 const rows = React.useMemo(() => { 198 const depth = calcDepthMap(data); 199 const withDepth = data.map((r) => ({ 200 ...r, 201 depth: Math.min(depth.get(r.displayId) ?? 0, 2), 202 })); 203 const withEffMin = attachEffectiveMinPriority(withDepth); 204 return withMoveFlags(withEffMin); 205 }, [data]); 206 207 // フィルタ(階層順を保持) 208 const filtered = React.useMemo(() => { 209 const needle = queryState.q.trim().toLowerCase(); 210 return rows.filter((r) => { 211 const passQ = 212 !needle || 213 `${r.displayId} ${r.title} ${r.href ?? ""}` 214 .toLowerCase() 215 .includes(needle); 216 const visible = !r.hidden; 217 const passStatus = 218 queryState.status === "ALL" || 219 (queryState.status === "ACTIVE" ? visible : !visible); 220 return passQ && passStatus; 221 }); 222 }, [rows, queryState.q, queryState.status]); 223 224 // ★ “現在の並び”を表すシグネチャ(displayId:order)で変化検知 225 const orderSig = React.useMemo( 226 () => data.map((d) => `${d.displayId}:${d.order}`).join("|"), 227 [data], 228 ); 229 const prevOrderSigRef = React.useRef(orderSig); 230 231 // refresh で order が変わったら処理中フラグを解除 232 React.useEffect(() => { 233 if (prevOrderSigRef.current !== orderSig) { 234 prevOrderSigRef.current = orderSig; 235 setMovingIds(new Set()); // 一括解除 236 } 237 }, [orderSig]); 238 239 // 不変更新ユーティリティ 240 const addMoving = (ids: string[]) => 241 setMovingIds((prev) => new Set([...prev, ...ids])); 242 243 const delToggling = (id: string) => 244 setTogglingIds((prev) => { 245 const next = new Set(prev); 246 next.delete(id); 247 return next; 248 }); 249 250 const withMoving = async <T,>( 251 ids: string[], 252 fn: () => Promise<T>, 253 ): Promise<T> => { 254 addMoving(ids); 255 const res = await fn(); 256 // 解除は orderSig 変化の useEffect が担当(ここでは外さない) 257 return res; 258 }; 259 260 // 兄弟のペアIDを拾って両方を処理中にする 261 const onMoveUp = async (displayId: string) => { 262 const me = rows.find((r) => r.displayId === displayId); 263 if (!me) return; 264 const siblings = rows 265 .filter((r) => r.parentId === me.parentId) 266 .sort((a, b) => a.order - b.order); 267 const i = siblings.findIndex((r) => r.displayId === displayId); 268 const pair = siblings[i - 1]; 269 const ids = pair ? [displayId, pair.displayId] : [displayId]; 270 271 await withMoving(ids, async () => { 272 const res = await moveMenuOrderAction(displayId, "up"); 273 if (res.ok) React.startTransition(() => router.refresh()); 274 return res; 275 }); 276 }; 277 278 const onMoveDown = async (displayId: string) => { 279 const me = rows.find((r) => r.displayId === displayId); 280 if (!me) return; 281 const siblings = rows 282 .filter((r) => r.parentId === me.parentId) 283 .sort((a, b) => a.order - b.order); 284 const i = siblings.findIndex((r) => r.displayId === displayId); 285 const pair = siblings[i + 1]; 286 const ids = pair ? [displayId, pair.displayId] : [displayId]; 287 288 await withMoving(ids, async () => { 289 const res = await moveMenuOrderAction(displayId, "down"); 290 if (res.ok) React.startTransition(() => router.refresh()); 291 return res; 292 }); 293 }; 294 295 // 可視トグル:開始時に pending+spinner を立て、data更新で自動解除 296 const onToggleActive = async (displayId: string, nextVisible: boolean) => { 297 setPendingVisible((prev) => new Map(prev).set(displayId, nextVisible)); 298 setTogglingIds((prev) => new Set(prev).add(displayId)); 299 const res = await toggleMenuHiddenAction(displayId, !nextVisible); 300 if (res.ok) React.startTransition(() => router.refresh()); 301 else { 302 setPendingVisible((prev) => { 303 const m = new Map(prev); 304 m.delete(displayId); 305 return m; 306 }); 307 delToggling(displayId); 308 } 309 }; 310 311 // data更新で期待状態になった行のトグル “処理中” を解除 312 React.useEffect(() => { 313 if (pendingVisible.size === 0) return; 314 let changed = false; 315 const nextPending = new Map(pendingVisible); 316 const nextToggling = new Set(togglingIds); 317 for (const [id, v] of pendingVisible) { 318 const row = data.find((r) => r.displayId === id); 319 if (row && !row.hidden === v) { 320 nextPending.delete(id); 321 nextToggling.delete(id); 322 changed = true; 323 } 324 } 325 if (changed) { 326 setPendingVisible(nextPending); 327 setTogglingIds(nextToggling); 328 } 329 }, [data, pendingVisible, togglingIds]); 330 331 const table = useReactTable({ 332 data: filtered as RowShape[], 333 columns, 334 state: { sorting, columnVisibility }, 335 onSortingChange: setSorting, 336 getCoreRowModel: getCoreRowModel(), 337 getSortedRowModel: getSortedRowModel(), 338 getPaginationRowModel: getPaginationRowModel(), 339 initialState: { pagination: { pageIndex: 0, pageSize: 20 } }, 340 meta: { 341 onMoveUp, 342 onMoveDown, 343 onToggleActive, 344 // ★ 追加:処理中セットを列側へ 345 movingIds, 346 togglingIds, 347 pendingVisible, 348 status: queryState.status, 349 setStatus, 350 }, 351 }); 352 353 // ページサイズの保存 354 React.useEffect(() => { 355 if (mounted) table.setPageSize(persisted.pageSize); 356 }, [mounted, persisted.pageSize, table]); 357 358 // CSV(可視列のみ/ヘッダは日本語表示) 359 const columnLabels = React.useMemo( 360 () => 361 ({ 362 displayId: "表示ID", 363 title: "タイトル", 364 href: "Path", 365 match: "一致", 366 minPriority: "しきい値", 367 order: "順序", 368 visible: "可視", 369 }) as const, 370 [], 371 ); 372 373 const onDownloadCsv = React.useCallback(() => { 374 const visibleLeaf = table 375 .getVisibleLeafColumns() 376 .map((c) => c.id) 377 .filter((id) => id !== "q") as ColId[]; 378 379 const headers = visibleLeaf.map((id) => columnLabels[id]); 380 381 const rowsCsv = filtered.map((r) => 382 visibleLeaf.map((id) => { 383 switch (id) { 384 case "displayId": 385 return r.displayId; 386 case "title": 387 return r.title; 388 case "href": 389 return r.href ?? ""; 390 case "match": 391 return r.match ?? ""; 392 case "minPriority": 393 return r.effMinPriority ?? ""; 394 case "order": 395 return r.order; 396 case "visible": 397 return !r.hidden ? "可視" : "非表示"; 398 default: 399 return ""; 400 } 401 }), 402 ); 403 404 const csv = buildCsv(headers, rowsCsv); 405 const ts = format(new Date(), "yyyyMMdd_HHmmss", { locale: ja }); 406 downloadCsv(`menus_${ts}.csv`, csv); 407 }, [filtered, table, columnLabels]); 408 409 return ( 410 <div className="space-y-3"> 411 <DatagridToolbar<ColId> 412 qTitle={`${columnLabels.displayId}/${columnLabels.title}/${columnLabels.href}`} 413 q={queryState.q} 414 onChangeQ={setQ} 415 columnOptions={Array.from(allColumnIds).map((id) => ({ 416 value: id, 417 label: columnLabels[id], 418 }))} 419 visibleColumnIds={effectiveVisibleColumnIds} 420 onChangeVisibleColumns={setVisibleColumnIds} 421 canDownloadData={canDownloadData} 422 onDownloadCsv={onDownloadCsv} 423 canEditData={false} 424 /> 425 426 {/* 件数・サマリ */} 427 <div className="flex items-center justify-between gap-3"> 428 <div className="text-sm">表示件数: {filtered.length}</div> 429 <div className="flex max-w-[60%] items-center justify-end gap-2"> 430 <DatagridSummary 431 mounted={mounted} 432 statusText={ 433 queryState.status === "ALL" 434 ? "可視: すべて" 435 : queryState.status === "ACTIVE" 436 ? "可視: 可視のみ" 437 : "可視: 非表示のみ" 438 } 439 visibleColsText={effectiveVisibleColumnIds 440 .map((id) => columnLabels[id]) 441 .join(", ")} 442 /> 443 <button 444 type="button" 445 className="text-muted-foreground shrink-0 cursor-pointer text-xs underline" 446 onClick={() => { 447 setQueryState((s) => ({ 448 ...s, 449 q: "", 450 status: "ALL", 451 cols: [ 452 "displayId", 453 "title", 454 "href", 455 "minPriority", 456 "order", 457 "visible", 458 ] as ColId[], 459 })); 460 setPersisted((p) => ({ ...p, pageSize: 20 })); 461 table.setPageSize(20); 462 }} 463 title="全フィルタ解除" 464 > 465 全フィルタ解除 466 </button> 467 </div> 468 </div> 469 470 {/* テーブル */} 471 <div className="overflow-x-auto rounded-md border pb-1"> 472 <Table 473 className="w-full" 474 data-testid="menus-table" 475 containerClassName={ 476 !canDownloadData && !canEditData 477 ? "max-h-[calc(100svh_-_244px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1" 478 : "max-h-[calc(100svh_-_284px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1" 479 } 480 > 481 <TableHeader className="bg-muted/60 supports-[backdrop-filter]:bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur"> 482 {table.getHeaderGroups().map((hg) => ( 483 <TableRow key={hg.id}> 484 {hg.headers.map((header) => ( 485 <TableHead 486 key={header.id} 487 style={{ width: header.column.getSize() }} 488 > 489 {header.isPlaceholder 490 ? null 491 : flexRender( 492 header.column.columnDef.header, 493 header.getContext(), 494 )} 495 </TableHead> 496 ))} 497 </TableRow> 498 ))} 499 </TableHeader> 500 <TableBody> 501 {table.getRowModel().rows.length ? ( 502 table.getRowModel().rows.map((row) => ( 503 <TableRow key={row.id}> 504 {row.getVisibleCells().map((cell) => ( 505 <TableCell 506 key={cell.id} 507 style={{ width: cell.column.getSize() }} 508 > 509 {flexRender( 510 cell.column.columnDef.cell, 511 cell.getContext(), 512 )} 513 </TableCell> 514 ))} 515 </TableRow> 516 )) 517 ) : ( 518 <TableRow> 519 <TableCell 520 colSpan={table.getAllColumns().length} 521 className="text-muted-foreground py-10 text-center text-sm" 522 > 523 条件に一致するメニューが見つかりませんでした。 524 </TableCell> 525 </TableRow> 526 )} 527 </TableBody> 528 </Table> 529 </div> 530 531 <DatagridPagination 532 table={table} 533 pageSize={table.getState().pagination.pageSize} 534 onChangePageSize={(n) => { 535 table.setPageSize(n); 536 setPersisted((p) => ({ ...p, pageSize: n })); 537 }} 538 /> 539 </div> 540 ); 541}
一覧テーブル全体を Datagrid 系コンポーネントへ移行しました。
これにより、メニュー一覧も他マスタと同様のUI・機能(検索・ソート・CSV出力など)を共有できるようになりました。
主要変更内容効果
DatagridToolbar / Summary / Pagination の採用一覧系UIの共通化操作性・デザイン統一
moveMenuOrderAction / toggleMenuHiddenActionサーバーアクション連携DBへ即時反映
movingIds / togglingIds / pendingVisible行単位の進行状態管理スムーズなUX
attachEffectiveMinPriorityしきい値の継承表示RBACとの整合性を保持
CSV出力表示列のみ動的生成一覧の再利用性向上
この改修で、可視切り替えや順序変更がサーバー反映されるまでの 一瞬のラグをUIで自然に吸収できるようになりました。
また、一覧の構造は他のマスタ系ページ(roles, users等)と完全に統一されています。
data-table.tsxで利用しているsrc/components/datagrid/datagrid-summary.tsx(実行中のフィルタの表示)とsrc/components/datagrid/datagrid-toolbar.tsx(キーワード検索等のツール)について、若干の変更を行なっています。これについては、9章で記載します。

この章のまとめ

項目BeforeAfter
データ取得モック (getMenus)DB (fetchMenusForList)
並び替えクライアント処理サーバーアクション連携
可視制御なしhidden + lockHiddenOverride対応
処理中表示なしスピナー+pending状態
UI構成独立実装Datagrid共通フレーム
このように /masters/menus は、RBAC・DB・UIの3層を統合した
フルスタックな「部署別メニュー管理ページ」として再構築されました。
次の章では、このUI更新を支えるアクション群とキャッシュ制御の仕組みを解説します。

8. 可視・順序アクションのServer Action化

この章では /masters/menus の「可視切り替え(hidden)」と「兄弟間の順序入れ替え(↑/↓)」を Server Action へ移行した実装を解説します。フロントは非同期操作の進行状態を表示しつつ、サーバ側で 認可・検証・DB更新・キャッシュ無効化 を一気通貫で行います。
対象:src/app/_actions/menus/update.ts
ts
1// src/app/_actions/menus/update.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { getEffectiveRole } from "@/lib/auth/effective-role"; 7import { revalidateMenusForDepartment } from "@/lib/sidebar/menu.fetch"; 8 9type ActionResult = { ok: true } | { ok: false; message: string }; 10 11// ADMIN 相当のしきい値(ユーザ更新と同基準) 12const ADMIN_PRIORITY_THRESHOLD = 100; 13 14/* ───────────────────────────────────────────── 15 ユーティリティ 16 ───────────────────────────────────────────── */ 17 18/** 呼び出しユーザの部署ID・実効ロールpriorityを取得し、ADMINか検証 */ 19async function requireAdminAndDepartment() { 20 const ses = await lookupSessionFromCookie(); 21 if (!ses.ok) return { ok: false as const, message: "認証が必要です。" }; 22 23 const me = await prisma.user.findUnique({ 24 where: { id: ses.userId }, 25 select: { 26 id: true, 27 isActive: true, 28 departmentId: true, 29 roleId: true, 30 departmentRoleId: true, 31 }, 32 }); 33 if (!me || !me.isActive) 34 return { ok: false as const, message: "ユーザが無効化されています。" }; 35 36 if (!me.departmentId) 37 return { ok: false as const, message: "部署が見つかりません。" }; 38 39 // 実効ロールのpriorityを解決 40 let eff = null as null | { priority: number }; 41 if (me.departmentRoleId) { 42 eff = await getEffectiveRole({ 43 departmentId: me.departmentId, 44 departmentRoleId: me.departmentRoleId, 45 }); 46 } else if (me.roleId) { 47 eff = await getEffectiveRole({ 48 departmentId: me.departmentId, 49 roleId: me.roleId, 50 }); 51 } 52 53 if (!eff || eff.priority < ADMIN_PRIORITY_THRESHOLD) 54 return { ok: false as const, message: "権限がありません。" }; 55 56 return { 57 ok: true as const, 58 userId: me.id, 59 departmentId: me.departmentId, 60 priority: eff.priority, 61 }; 62} 63 64/** displayId → Menu(テンプレ) */ 65async function findMenuByDisplayId(displayId: string) { 66 const m = await prisma.menu.findUnique({ 67 where: { displayId }, 68 select: { 69 id: true, 70 displayId: true, 71 href: true, 72 isActive: true, // テンプレが無効なら対象外 73 parentId: true, 74 sortOrder: true, 75 lockHiddenOverride: true, 76 }, 77 }); 78 if (!m) throw new Error(`Menu not found: ${displayId}`); 79 return m; 80} 81 82/* ───────────────────────────────────────────── 83 アクション: 可視(=hidden)の切替 84 部署ごとの DepartmentMenu.hidden を true/false で上書き 85 ───────────────────────────────────────────── */ 86export async function toggleMenuHiddenAction( 87 displayId: string, 88 nextHidden: boolean, // true=非表示, false=表示 89): Promise<ActionResult> { 90 const auth = await requireAdminAndDepartment(); 91 if (!auth.ok) return auth; 92 93 const menu = await findMenuByDisplayId(displayId); 94 if (!menu.isActive) { 95 return { ok: false, message: "このメニューはテンプレート側で無効です。" }; 96 } 97 // ★ ここで保護 98 // ★ DB で制御:禁止されているなら拒否 99 if (menu.lockHiddenOverride) { 100 return { ok: false, message: "このメニューは非表示にできません。" }; 101 } 102 // nextHidden=false → テンプレ既定(=可視)にもどす → hiddenOverride: null 103 // nextHidden=true → 非表示の明示 → hiddenOverride: true 104 await prisma.departmentMenu.upsert({ 105 where: { 106 departmentId_menuId: { departmentId: auth.departmentId, menuId: menu.id }, 107 }, 108 update: { hiddenOverride: nextHidden ? true : null }, 109 create: { 110 departmentId: auth.departmentId, 111 menuId: menu.id, 112 hiddenOverride: nextHidden ? true : null, 113 }, 114 }); 115 116 revalidateMenusForDepartment(auth.departmentId); 117 return { ok: true }; 118} 119 120/* ───────────────────────────────────────────── 121 アクション: 兄弟間の順序入れ替え(↑/↓) 122 DepartmentMenu.sortOrder をswap(無ければテンプレ値を基準に生成) 123 ───────────────────────────────────────────── */ 124export async function moveMenuOrderAction( 125 displayId: string, 126 direction: "up" | "down", 127): Promise<ActionResult> { 128 // 1) 認証・権限 129 const auth = await requireAdminAndDepartment(); 130 if (!auth.ok) return auth; 131 132 // 2) 対象と兄弟一覧 133 const me = await findMenuByDisplayId(displayId); 134 if (!me.isActive) { 135 return { ok: false, message: "このメニューはテンプレート側で無効です。" }; 136 } 137 138 // 同一親・テンプレ isActive=true の兄弟だけが並び替え対象 139 const siblings = await prisma.menu.findMany({ 140 where: { parentId: me.parentId, isActive: true }, 141 select: { 142 id: true, 143 displayId: true, 144 sortOrder: true, 145 departmentMenus: { 146 where: { departmentId: auth.departmentId }, 147 select: { sortOrder: true }, 148 take: 1, 149 }, 150 }, 151 }); 152 153 const list = siblings 154 .map((s) => ({ 155 id: s.id, 156 displayId: s.displayId, 157 order: s.departmentMenus[0]?.sortOrder ?? s.sortOrder, 158 })) 159 .sort((a, b) => a.order - b.order); 160 161 const idx = list.findIndex((x) => x.displayId === displayId); 162 if (idx < 0) return { ok: false, message: "メニューが見つかりません。" }; 163 164 const pair = direction === "up" ? list[idx - 1] : list[idx + 1]; 165 if (!pair) return { ok: true }; // 端っこ:何もしない(エラーにしない) 166 167 const a = list[idx]; 168 const b = pair; 169 170 // 3) swap(2件トランザクション) 171 await prisma.$transaction([ 172 prisma.departmentMenu.upsert({ 173 where: { 174 departmentId_menuId: { departmentId: auth.departmentId, menuId: a.id }, 175 }, 176 update: { sortOrder: b.order }, 177 create: { 178 departmentId: auth.departmentId, 179 menuId: a.id, 180 sortOrder: b.order, 181 }, 182 }), 183 prisma.departmentMenu.upsert({ 184 where: { 185 departmentId_menuId: { departmentId: auth.departmentId, menuId: b.id }, 186 }, 187 update: { sortOrder: a.order }, 188 create: { 189 departmentId: auth.departmentId, 190 menuId: b.id, 191 sortOrder: a.order, 192 }, 193 }), 194 ]); 195 196 // 4) キャッシュ破棄 197 revalidateMenusForDepartment(auth.departmentId); 198 199 return { ok: true }; 200}
txt
1[Toggle 可視] 2UI(Switch) → toggleMenuHiddenAction(displayId, nextHidden) 3 ├─ requireAdminAndDepartment() // 認証・所属部署・実効priority>=100 を保証 4 ├─ findMenuByDisplayId() // lockHiddenOverride/有効性を確認 5 ├─ upsert DepartmentMenu.hiddenOverride 6 └─ revalidateMenusForDepartment(deptId) 7 8[Move ↑/↓] 9UI(Button) → moveMenuOrderAction(displayId, "up"|"down") 10 ├─ requireAdminAndDepartment() 11 ├─ findMenuByDisplayId() 12 ├─ siblings取得(同一parentId & isActive=true) 13 ├─ effectiveOrder = override.sortOrder ?? template.sortOrder 14 ├─ swap 2件を $transaction で upsert 15 └─ revalidateMenusForDepartment(deptId)

認証・権限:requireAdminAndDepartment()

  • セッション確認lookupSessionFromCookie() でログイン必須。
  • 基本属性user.isActive / departmentId を検証。
  • 実効ロール解決:部署ロール優先で getEffectiveRole() を呼び分け。
  • しきい値ADMIN_PRIORITY_THRESHOLD = 100 を満たさなければ拒否。
  • 戻り値{ ok: true, userId, departmentId, priority } or { ok:false, message }
UI ではこのメッセージを直接表示しない設計ですが、戻り値に含めることでクライアント側の再試行やトースト表示へ拡張できます。

可視切り替え:toggleMenuHiddenAction(displayId, nextHidden)

  • 保護対象の拒否lockHiddenOverride が true なら 非表示化を禁止
  • テンプレ有効性:テンプレ側 isActive=false は対象外(早期終了)。
  • 上書きの規約
    • nextHidden = false(=表示に戻す)→ hiddenOverride: null(テンプレ採用)
    • nextHidden = true(=非表示にする)→ hiddenOverride: true
  • DB更新departmentMenu.upsert()部署単位の上書き を作成/更新。
  • キャッシュrevalidateMenusForDepartment(deptId)unstable_cache のタグを破棄。
UI側では pendingVisibletogglingIds を併用。
期待される最終状態 を描画しつつ、router.refresh() により実データ確定後に自動で処理中解除されます。

兄弟間の順序入れ替え:moveMenuOrderAction(displayId, "up" | "down")

  • 対象スコープ:同一 parentIdテンプレ isActive=true の兄弟のみ。
  • effectiveOrderDepartmentMenu.sortOrder ?? Menu.sortOrder を採用して並べ替え。
  • 端っこ判定:先頭で「up」、末尾で「down」は 何もしない(okで返す)
  • 2件swap$transactionA/Bの sortOrder を相互に upsert
    • 既に override があれば更新、なければ作成。
  • キャッシュ破棄:部署タグで invalidate → SSR/RSC が新順序を取得。
UI側では movingIds を使って 該当2行にスピナー を表示。
並びの変化は router.refresh() 後に orderシグネチャ の差分検知で自動解除します。

エラーとメッセージ方針

段階主なエラー返却
認証/権限未ログイン・権限不足・部署未設定{ ok:false, message }
対象検証メニュー未存在・テンプレ無効・保護対象{ ok:false, message }
DB更新例外(まれ)例外伝播 or { ok:false, message }
例外時はログ基盤に記録することを推奨。クライアントにはユーザ向け日本語メッセージを返却するのが無難です。

実装のポイント(要約)

  • セキュリティ:Server Action で認可を強制(実効priority>=100)。
  • 一貫性:トグルは null/true2値運用 で「テンプレ準拠か否か」を明確化。
  • 整合性:順序入れ替えは 2件トランザクション で不整合を回避。
  • UXpendingVisible / togglingIds / movingIds により 操作→反映 の間を滑らかに。
  • キャッシュ:部署タグ単位の revalidate局所的・即時 更新を実現。
以上で、可視/順序の変更は 部署単位の上書き としてDBへ安全に反映され、
キャッシュ破棄を通じて一覧・サイドバー・ガード判定にも 即座に反映 されます。

9. DataTable構成の改良ポイント

この章では、DataTable 共通構成の2つの補助コンポーネントを改善し、
「情報の過不足」と「検索の分かりやすさ」 の両面で使いやすさを高めた内容を解説します。
対象は以下の2ファイルです。
  • src/components/datagrid/datagrid-summary.tsx
  • src/components/datagrid/datagrid-toolbar.tsx

datagrid-summary.tsx:日付フィルタのない一覧にも対応

tsx
1// src/components/datagrid/datagrid-summary.tsx 2"use client"; 3 4import * as React from "react"; 5import type { DateRange } from "react-day-picker"; 6import { fmtRangeLocal, fmtRangeStable } from "@/lib/datagrid/date-io"; 7 8type Props = { 9 mounted: boolean; 10 roleText?: string; // 例: "ロール: すべて" or "ロール: 管理者, 閲覧者" 11 statusText: string; // 例: "状態: すべて" 12 createdTitle?: string; // 例: "登録" 13 updatedTitle?: string; // 例: "更新" 14 createdRangeISO?: { from?: string; to?: string }; 15 updatedRangeISO?: { from?: string; to?: string }; 16 createdRange?: DateRange; // mounted=true のときローカル表示に利用 17 updatedRange?: DateRange; // 同上 18 visibleColsText: string; // "表示ID, 氏名, ..." 19}; 20 21export function DatagridSummary({ 22 mounted, 23 roleText, 24 statusText, 25 createdTitle, 26 updatedTitle, 27 createdRangeISO, 28 updatedRangeISO, 29 createdRange, 30 updatedRange, 31 visibleColsText, 32}: Props) { 33 // タイトルがあるときだけ算出(無駄計算を避ける) 34 const createdText = React.useMemo( 35 () => 36 createdTitle 37 ? mounted 38 ? fmtRangeLocal(createdRange) 39 : fmtRangeStable(createdRangeISO) 40 : undefined, 41 [createdTitle, mounted, createdRange, createdRangeISO], 42 ); 43 44 const updatedText = React.useMemo( 45 () => 46 updatedTitle 47 ? mounted 48 ? fmtRangeLocal(updatedRange) 49 : fmtRangeStable(updatedRangeISO) 50 : undefined, 51 [updatedTitle, mounted, updatedRange, updatedRangeISO], 52 ); 53 54 const parts = [ 55 roleText, 56 statusText, 57 createdTitle && createdText ? `${createdTitle}: ${createdText}` : undefined, 58 updatedTitle && updatedText ? `${updatedTitle}: ${updatedText}` : undefined, 59 `表示: ${visibleColsText}`, 60 ]; 61 const summary = parts.filter(Boolean).join(" / "); 62 63 return ( 64 <div 65 className="text-muted-foreground truncate text-right text-xs" 66 title={summary} 67 > 68 {summary} 69 </div> 70 ); 71}
従来の DatagridSummary は、登録日・更新日レンジを常に前提としており、
日付フィルタが存在しない一覧(例: /masters/menus)では
登録: すべて / 更新: すべて といった冗長な出力になっていました。
今回の改修では、createdTitle / updatedTitle の指定有無を判定条件に追加し、
日付レンジが存在しない場合は、その項目をまるごと非表示 にしています。
また、内部処理も useMemo 化しており、
タイトル未指定時には日付フォーマット関数を呼ばないため、
不要な再計算を避けてレンダリング効率も向上しています。
さらに roleText をオプショナル化したことで、
ロールフィルタを持たない一覧でも自然な文構成で出力されるようになりました。

datagrid-toolbar.tsx:検索対象カラムの明示化

tsx
1// src/components/datagrid/datagrid-toolbar.tsx 2"use client"; 3 4import * as React from "react"; 5import { Button } from "@/components/ui/button"; 6import { Input } from "@/components/ui/input"; 7import { 8 Popover, 9 PopoverTrigger, 10 PopoverContent, 11} from "@/components/ui/popover"; 12import * as PopoverPrimitive from "@radix-ui/react-popover"; 13import { Columns3, FileDown } from "lucide-react"; 14import Link from "next/link"; 15import { 16 ColumnsChecklist, 17 type ColumnOption, 18} from "@/components/filters/columns-checklist"; 19 20type Props<ColId extends string> = { 21 q: string; 22 qTitle?: string; 23 onChangeQ: (v: string) => void; 24 columnOptions: ColumnOption[]; 25 visibleColumnIds: ColId[]; 26 onChangeVisibleColumns: (ids: ColId[]) => void; 27 canDownloadData?: boolean; 28 onDownloadCsv?: () => void; 29 canEditData?: boolean; 30 newHref?: string; 31}; 32 33export function DatagridToolbar<ColId extends string>({ 34 q, 35 qTitle, 36 onChangeQ, 37 columnOptions, 38 visibleColumnIds, 39 onChangeVisibleColumns, 40 canDownloadData, 41 onDownloadCsv, 42 canEditData, 43 newHref, 44}: Props<ColId>) { 45 return ( 46 <div className="md: flex flex-wrap items-center justify-between gap-3 md:flex-nowrap"> 47 <Input 48 name="filter-q" 49 value={q} 50 onChange={(e) => onChangeQ(e.target.value)} 51 placeholder={qTitle ? `検索:${qTitle}` : "キーワードで検索"} 52 className="w-[270px] basis-full text-sm md:basis-auto" 53 aria-label="検索キーワード" 54 /> 55 <div className="ml-auto flex items-center gap-2"> 56 <Popover> 57 <PopoverTrigger asChild> 58 <Button 59 variant="outline" 60 className="cursor-pointer" 61 title="表示項目の変更" 62 > 63 <Columns3 className="h-4 w-4" /> 64 表示項目 65 </Button> 66 </PopoverTrigger> 67 <PopoverContent align="end" className="w-[320px] p-0"> 68 <ColumnsChecklist 69 value={visibleColumnIds} 70 onChange={(ids) => onChangeVisibleColumns(ids as ColId[])} 71 options={columnOptions} 72 footer={ 73 <PopoverPrimitive.Close asChild> 74 <Button 75 variant="ghost" 76 size="sm" 77 type="button" 78 className="cursor-pointer" 79 > 80 閉じる 81 </Button> 82 </PopoverPrimitive.Close> 83 } 84 /> 85 </PopoverContent> 86 </Popover> 87 88 {canDownloadData && onDownloadCsv && ( 89 <Button 90 variant="outline" 91 onClick={onDownloadCsv} 92 className="cursor-pointer" 93 > 94 <FileDown className="h-4 w-4" /> 95 CSV 96 </Button> 97 )} 98 99 {canEditData && newHref && ( 100 <Button asChild> 101 <Link href={newHref}>新規登録</Link> 102 </Button> 103 )} 104 </div> 105 </div> 106 ); 107}
もうひとつの改善点は、検索欄のプレースホルダに
「どの項目が検索対象なのか」を明示できるようにしたことです。
これまでの placeholder="キーワードで検索" は汎用的すぎて、
複数カラム検索(例:表示ID・タイトル・Path など)の対象が不明瞭でした。
今回の改修では新しい qTitle プロパティを追加し、
検索対象を明示したプレースホルダを動的に表示できるようにしています。
これにより、利用者は入力時点で「どの項目がヒットするか」を直感的に理解できます。
また、検索フィールドの使い方をヘルプ文で説明する必要もなくなり、
データグリッド全体の一貫性と操作性が向上しました。

10. Prisma Seedでメニュー初期データ登録

この章では、メニューをDBへ初期登録するための Prisma Seedスクリプト を紹介します。
対象ファイルは prisma/seed-menus.ts で、単純に静的データを挿入するのみの構成です。

実装内容

ts
1// prisma/seed-menus.ts 2import { PrismaClient, MenuMatchMode } from "@prisma/client"; 3 4const prisma = new PrismaClient(); 5 6/** メニュー定義(displayIdはDBに任せる) */ 7type SeedMenu = { 8 key: string; // 内部キーとして使う(旧displayId相当) 9 parentKey: string | null; 10 order: number; 11 title: string; 12 href?: string; 13 iconName?: string; 14 match: "exact" | "prefix" | "regex"; 15 pattern?: string; 16 minPriority?: number; 17 isSection: boolean; 18 isActive: boolean; 19 hidden: boolean; 20 lockHiddenOverride: boolean; 21}; 22 23const SEED_MENUS: SeedMenu[] = [ 24 { 25 key: "root-dashboard", 26 parentKey: null, 27 order: 0, 28 title: "ダッシュボード", 29 iconName: "SquareTerminal", 30 match: "prefix", 31 isSection: true, 32 isActive: true, 33 hidden: false, 34 lockHiddenOverride: false, 35 }, 36 { 37 key: "root-docs", 38 parentKey: null, 39 order: 1, 40 title: "ドキュメント", 41 iconName: "BookOpen", 42 match: "prefix", 43 isSection: true, 44 isActive: true, 45 hidden: false, 46 lockHiddenOverride: false, 47 }, 48 { 49 key: "root-settings", 50 parentKey: null, 51 order: 2, 52 title: "設定", 53 iconName: "Settings2", 54 match: "prefix", 55 minPriority: 100, 56 isSection: true, 57 isActive: true, 58 hidden: false, 59 lockHiddenOverride: false, 60 }, 61 { 62 key: "root-personal", 63 parentKey: null, 64 order: 3, 65 title: "個人設定", 66 iconName: "Settings2", 67 match: "prefix", 68 isSection: true, 69 isActive: true, 70 hidden: true, 71 lockHiddenOverride: false, 72 }, 73 { 74 key: "dashboard-overview", 75 parentKey: "root-dashboard", 76 order: 0, 77 title: "概要", 78 href: "/dashboard", 79 match: "exact", 80 isSection: false, 81 isActive: true, 82 hidden: false, 83 lockHiddenOverride: false, 84 }, 85 { 86 key: "docs-tutorial", 87 parentKey: "root-docs", 88 order: 0, 89 title: "チュートリアル", 90 href: "/tutorial", 91 match: "exact", 92 isSection: false, 93 isActive: true, 94 hidden: false, 95 lockHiddenOverride: false, 96 }, 97 { 98 key: "docs-changelog", 99 parentKey: "root-docs", 100 order: 1, 101 title: "更新履歴", 102 href: "/changelog", 103 match: "exact", 104 isSection: false, 105 isActive: true, 106 hidden: false, 107 lockHiddenOverride: false, 108 }, 109 { 110 key: "settings-masters", 111 parentKey: "root-settings", 112 order: 0, 113 title: "マスタ管理", 114 href: "/masters", 115 match: "prefix", 116 isSection: false, 117 isActive: true, 118 hidden: false, 119 lockHiddenOverride: false, 120 }, 121 { 122 key: "settings-users", 123 parentKey: "root-settings", 124 order: 1, 125 title: "ユーザ管理", 126 href: "/users", 127 match: "prefix", 128 isSection: false, 129 isActive: true, 130 hidden: false, 131 lockHiddenOverride: false, 132 }, 133 { 134 key: "masters-list", 135 parentKey: "settings-masters", 136 order: 0, 137 title: "マスタ一覧", 138 href: "/masters", 139 match: "exact", 140 isSection: false, 141 isActive: true, 142 hidden: false, 143 lockHiddenOverride: false, 144 }, 145 { 146 key: "masters-roles", 147 parentKey: "settings-masters", 148 order: 1, 149 title: "ロール管理", 150 href: "/masters/roles", 151 match: "prefix", 152 isSection: false, 153 isActive: true, 154 hidden: false, 155 lockHiddenOverride: false, 156 }, 157 { 158 key: "masters-menus", 159 parentKey: "settings-masters", 160 order: 2, 161 title: "メニュー管理", 162 href: "/masters/menus", 163 match: "prefix", 164 isSection: false, 165 isActive: true, 166 hidden: false, 167 lockHiddenOverride: false, 168 }, 169 { 170 key: "users-list", 171 parentKey: "settings-users", 172 order: 0, 173 title: "一覧", 174 href: "/users", 175 match: "exact", 176 isSection: false, 177 isActive: true, 178 hidden: false, 179 lockHiddenOverride: false, 180 }, 181 { 182 key: "users-new", 183 parentKey: "settings-users", 184 order: 1, 185 title: "新規登録", 186 href: "/users/new", 187 match: "exact", 188 isSection: false, 189 isActive: true, 190 hidden: true, 191 lockHiddenOverride: false, 192 }, 193 { 194 key: "users-password", 195 parentKey: "settings-users", 196 order: 2, 197 title: "パスワード再発行", 198 href: "/users/password-request", 199 match: "exact", 200 isSection: false, 201 isActive: true, 202 hidden: false, 203 lockHiddenOverride: false, 204 }, 205 { 206 key: "users-email-change", 207 parentKey: "settings-users", 208 order: 3, 209 title: "メールアドレス変更の承認", 210 href: "/users/email-change-requests", 211 match: "exact", 212 isSection: false, 213 isActive: true, 214 hidden: false, 215 lockHiddenOverride: false, 216 }, 217 { 218 key: "personal-profile", 219 parentKey: "root-personal", 220 order: 0, 221 title: "プロフィール編集", 222 href: "/profile", 223 match: "exact", 224 isSection: false, 225 isActive: true, 226 hidden: true, 227 lockHiddenOverride: false, 228 }, 229 { 230 key: "personal-email", 231 parentKey: "personal-profile", 232 order: 0, 233 title: "メールアドレス変更", 234 href: "/profile/email", 235 match: "exact", 236 isSection: false, 237 isActive: true, 238 hidden: true, 239 lockHiddenOverride: false, 240 }, 241 { 242 key: "personal-password", 243 parentKey: "personal-profile", 244 order: 1, 245 title: "パスワード変更", 246 href: "/profile/password", 247 match: "exact", 248 isSection: false, 249 isActive: true, 250 hidden: true, 251 lockHiddenOverride: false, 252 }, 253 { 254 key: "personal-verify", 255 parentKey: "personal-profile", 256 order: 2, 257 title: "メールアドレス変更の確認", 258 href: "/profile/email/verify", 259 match: "exact", 260 isSection: false, 261 isActive: true, 262 hidden: true, 263 lockHiddenOverride: false, 264 }, 265]; 266 267async function main() { 268 console.log("🌱 Seeding Menus (auto displayId)..."); 269 270 const idByKey = new Map<string, string>(); 271 272 for (const m of SEED_MENUS) { 273 const parentId = m.parentKey ? (idByKey.get(m.parentKey) ?? null) : null; 274 275 const record = await prisma.menu.create({ 276 data: { 277 parentId, 278 title: m.title, 279 href: m.isSection ? null : (m.href ?? null), 280 isExternal: null, 281 iconName: m.iconName ?? null, 282 match: m.match as MenuMatchMode, 283 pattern: m.pattern ?? null, 284 minPriority: m.minPriority ?? null, 285 isSection: m.isSection, 286 sortOrder: m.order, 287 remarks: null, 288 hidden: m.hidden, 289 lockHiddenOverride: m.lockHiddenOverride, 290 isActive: m.isActive, 291 }, 292 select: { id: true, displayId: true }, 293 }); 294 295 idByKey.set(m.key, record.id); 296 console.log(` + ${m.title} (${record.displayId})`); 297 } 298 299 console.log("✅ Menus seeded successfully."); 300} 301 302main() 303 .then(async () => await prisma.$disconnect()) 304 .catch(async (e) => { 305 console.error("❌ Menu seeding failed:", e); 306 await prisma.$disconnect(); 307 process.exit(1); 308 });
SEED_MENUS 配列に全メニュー階層を定義し、main() 内で順次 prisma.menu.create() を実行します。
keyparentKey によって親子関係を表現し、Prismaが自動採番する displayId を利用して階層構造を再現します。
登録項目は以下の通りです。
項目説明
title表示名
href対応パス(セクションはnull)
isSectionグループ見出しかどうか
minPriority表示に必要な権限しきい値
hidden初期状態の可視・非表示
lockHiddenOverride部署での上書き禁止フラグ

実行方法と用途

開発・ステージング環境で次のコマンドを実行するだけで初期データが投入されます。
zsh
1npx tsx prisma/seed-menus.ts

ポイント

  • Prisma Seedを用いて初期メニューを自動登録
  • key / parentKey による階層構成
  • 各環境で npx prisma db seed 一行で再構築可能
これで、開発・本番間のメニュー構成差異を完全に防げるようになりました。

11. まとめと今後の展開

今回の「管理画面フォーマット開発編 #10」では、メニュー構成をDB化し、部署単位で可視・順序を上書きできる仕組みを整えました。
これにより、モックから脱却し、実際のRBAC運用へと近づいた段階です。

本記事で実現したポイント

  • Menu テーブルを中心にした DBベースのサイドバー管理
  • DepartmentMenu による 部署単位の表示・非表示や順序の上書き
  • lockHiddenOverride による 安全な非表示制御
  • Guard処理(認可ロジック)のDB対応による ページ単位のアクセス制御
  • Prisma Seed による メニュー初期登録の自動化
これらの更新により、サイドバー・ガード・マスタ画面がすべてDB連動化され、
運用中にも柔軟にメニュー構成を変えられるようになりました。

今後の展開

次回「[管理画面フォーマット開発編 #11] パスワード忘れ導線をDB連携する」では、
ユーザーがパスワードを再設定できるフローを実装します。
これまでの /users/password-request ページはUIのみの構成でしたが、
次章では以下を扱います:
  • PasswordResetRequest テーブルの設計
  • メール送信(トークン付きリンク)の実装
  • 受信後の再設定画面とトークン検証処理
  • RBAC・セッションとの整合性チェック
認証とセキュリティの要となるパートであり、
次回からはいよいよ「アカウント回復フロー」の完成を目指します。
今回で、メニュー管理機能はUI・DBともに安定稼働できる形となりました。
今後はユーザー周り(パスワード・メール再認証・プロフィール更新など)へと進み、
本格的な運用想定の「管理画面フォーマット」として磨きをかけていきます。

参考文献

これらを踏まえ、今回のメニュー管理DB化では「RBAC・キャッシュ・UI更新」の三層連携を意識した構成としています。
この記事の執筆・編集担当
DE

松本 孝太郎

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

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