![[管理画面フォーマット開発編 #10] メニュー管理UIをDB連携する](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-menu%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #10メニュー管理UIをDB連携する
グローバルで一貫したMenuテーブルを保ちながら、部署ごとにメニュー表示をカスタマイズ
初回公開日
最終更新日
0. はじめに
本記事では、これまでモックで動かしていたサイドバーのナビゲーションを データベース連携 に移行し、部署ごとの「可視/非表示」「並び順」上書き、および 権限ガード(RBAC) と統合するまでを整理します。
結果として、サーバサイドで合成したメニューをレイアウトから供給し、UIでは一覧(/masters/menus)で安全に操作・確認できる構成に刷新します。
結果として、サーバサイドで合成したメニューをレイアウトから供給し、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.ts | Menu + 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
上記のように「多対多(中間)」の関係を
部署ごとに
親子関係や href、優先度などの構造はすべてテンプレート側 (
DepartmentMenu
が担い、部署ごとに
Menu
の「可視・順序・有効状態」を個別に上書きできる仕組みです。親子関係や href、優先度などの構造はすべてテンプレート側 (
Menu
) に保持されます。Prismaモデルの変更点
Department
・Menu
モデルそれぞれに逆リレーションを追加し、新たに
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+}
このモデルでは
「同じ部署・同じメニューの上書きレコードは1件だけ」という一意制約を設定しています。
さらに、
@@unique([departmentId, menuId])
によって「同じ部署・同じメニューの上書きレコードは1件だけ」という一意制約を設定しています。
さらに、
onDelete: Restrict
により、部署やメニュー削除時の参照整合性を保証しています。主なカラムと役割
カラム名 | 型 | 意味 | 備考 |
---|---|---|---|
isEnabled | Boolean? | 有効/無効の上書き | null の場合はテンプレート既定(=有効)を使用 |
hiddenOverride | Boolean? | 可視/非表示の上書き | null の場合はテンプレートの hidden 値を使用 |
sortOrder | Int? | 並び順の上書き | 未設定時はテンプレートの順序を継承 |
remarks | String? | 管理用メモ | 上書き理由などの記録に利用 |
また、テンプレート側の Menu モデルには lockHiddenOverride カラムを追加しています。
これにより、「このメニューは非表示にしてはいけない」という安全装置を設定できるようになりました。
マイグレーションと型更新
zsh
1npx prisma migrate dev --name add-department-menu-lockhidden
2npx prisma generate
マイグレーションを実行すると
Prisma Client に新しい型定義が反映されます。
以降の実装ではこの
DepartmentMenu
テーブルが生成され、Prisma Client に新しい型定義が反映されます。
以降の実装ではこの
DepartmentMenu
を用いて、部署単位で柔軟な上書きが可能になります。設計のポイント
- null 値を「テンプレート継承」として扱う設計にすることで、上書きがない場合もデフォルト動作を保てる。
- 部署ごとの設定をテンプレート本体と分離し、構成の安定性と柔軟性を両立。
- lockHiddenOverride によって、誤操作で重要メニューを隠すリスクを防止。
- 一意制約とインデックス設計により、検索・更新パフォーマンスを確保。
この章では、データモデルとしての基盤を整備しました。
次の章では、この
次の章では、この
DepartmentMenu
を利用して実際にメニュー情報を取得・合成する仕組みを作ります。2. Menuテーブルの拡張と lockHiddenOverride
この章では、
前章で
この章ではそれを踏まえ、テンプレート定義側(Menu)で守るべき制約や保護設定を型レベルで強化 します。
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
に加えて、以下のような新しいプロパティを追加しています。プロパティ | 型 | 既定値 | 説明 |
---|---|---|---|
lockHiddenOverride | boolean | false | 部署単位の非表示上書きを禁止するフラグ |
このプロパティにより、メニューの一覧や編集フォームで「非表示スイッチを無効化」できるようになります。
たとえば、
たとえば、
/masters/menus
ページそのものを隠すことを防ぐといったケースで有効です。次の章では、このスキーマを活かして実際にメニュー情報を取得・合成する処理を実装します。
3. メニュー取得ロジックの新実装
この章では、新しく実装した メニュー取得処理(
従来は固定データ(モック)でメニューを構築していましたが、
今回の実装では Prisma + Next.js のサーバーキャッシュ機能 を組み合わせ、
「部署単位のメニュー構成」をリアルタイムに合成できるようになりました。
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──────┘
この関数では次のようなルールで合成が行われます。
属性 | 上書き可否 | 優先順位 | 説明 |
---|---|---|---|
isEnabled | ✅ | DepartmentMenu → Menu | 無効化設定(null なら有効扱い) |
hidden | ✅ | DepartmentMenu → Menu | 非表示上書き(部署単位で制御) |
sortOrder | ✅ | DepartmentMenu → 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]
2 ↓
3revalidateMenusForDepartment("dept-uuid")
4 ↓
5再取得時に fetchMenusForDepartment() が再評価される
このように、 キャッシュの粒度を部署レベルに限定 しているため、
他の部署のメニュー構成に影響を与えることなく安全に再描画できます。
他の部署のメニュー構成に影響を与えることなく安全に再描画できます。
実装上のポイント(まとめ)
観点 | 目的 | 採用技術 |
---|---|---|
データ合成 | テンプレート + 上書きの統合 | Prisma 多段 select/map |
キャッシュ制御 | 部署単位の再利用性向上 | Next.js unstable_cache |
再検証 | 更新時のみ対象部署を破棄 | revalidateTag |
型安全性 | Zod による MenuRecord で整合 | TypeScript + Zod |
この章では、メニュー構成を動的に組み立てる「取得レイヤ」を完成させました。
次の章では、このデータを実際の Sidebar コンポーネントへ渡し、
RBAC と統合して表示制御する仕組みを実装します。
次の章では、このデータを実際の Sidebar コンポーネントへ渡し、
RBAC と統合して表示制御する仕組みを実装します。
4. Sidebar構成の更新 ─ layoutレベルでメニューを注入
この章では、メニュー取得処理をサイドバーへ統合するために
これにより、ログインユーザーの所属部署に応じた Sidebar を即時に生成できるようになりました。
/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 に対して、ユーザーごとの部署設定を反映できるようになりました。
全ページ共通の Sidebar に対して、ユーザーごとの部署設定を反映できるようになりました。
SidebarWithMenus コンポーネントの役割
新たに追加した
これにより、サーバー上でメニューをフェッチしてから Sidebar を構築 でき、クライアント側での二段階ロードを防ぎます。
SidebarWithMenus
は、RSC(Server Component)として動作します。これにより、サーバー上でメニューをフェッチしてから Sidebar を構築 でき、クライアント側での二段階ロードを防ぎます。
txt
1[SSRフロー]
2
3lookupSessionFromCookie()
4 ↓
5 prisma.user.findUnique()
6 ↓
7 fetchMenusForDepartment()
8 ↓
9 AppSidebar(records=部署別メニュー)
10 ↓
11 Sidebar を含むページをSSRで描画
この仕組みを導入したことで、
AppSidebar
は純粋な表示専用コンポーネントになり、 UIとデータ取得の責務分離 が明確になりました。修正の目的と効果(まとめ)
観点 | 改善前 | 改善後 |
---|---|---|
メニュー取得 | クライアント側でモックデータ | サーバー側で Prisma + Cache による取得 |
部署対応 | 非対応(全ユーザー共通) | 所属部署ごとに差し替え |
初期描画 | 空サイドバー → クライアント再描画 | SSR時点で完全描画 |
メンテナンス性 | AppSidebar 内に依存ロジック | layout.tsx に集約(責務分離) |
この変更により、サイドバーの構築がより柔軟かつ安全になりました。
SSR 時点でメニューが確定するため、初回ロードも安定し、
認可(RBAC)と UI の整合性を自然に保てる構成になっています。
SSR 時点でメニューが確定するため、初回ロードも安定し、
認可(RBAC)と UI の整合性を自然に保てる構成になっています。
次の章では、この
records
を受け取る AppSidebar
側の更新内容を解説します。5. AppSidebarの改修 ─ RBACフィルタ+hidden除外
この章では、Sidebar の UI コンポーネント
部署別メニューを受け取り、RBAC(権限優先度)フィルタ と hidden除外処理 を統合した改修内容を解説します。
これにより、認可ロジックとメニュー表示が完全に同期し、部署単位での動的な表示制御が可能になりました。
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 に基づいた正確なメニュー構成を反映できます。
RBAC フィルタ処理の流れ
txt
1AppSidebar
2 ↓
3records(部署別メニュー)
4 ↓ filter(r => !r.hidden)
5 ↓ filterMenuRecordsByPriority(records, rolePriority)
6 ↓ toMenuTree(filtered)
7 ↓ NavMain(items=tree)
filterMenuRecordsByPriority()
は、ユーザーの rolePriority
に基づいてアクセス可能なしきい値(
minPriority
)を下回るメニューを除外します。さらに、hidden フラグ付きの項目はあらかじめ取り除かれているため、
実際のサイドバーには「可視メニューのみ」が確実に表示されます。
実際のサイドバーには「可視メニューのみ」が確実に表示されます。
型定義とProps構成
AppSidebar
は以下のように型を拡張しています。項目 | 型 | 説明 |
---|---|---|
records | MenuRecord[] | layout側から渡される部署別メニュー |
props | Sidebar の既存props | collapsible設定やクラス名など |
この
SSRとCSRの境界をまたいだ一貫したメニュー表示が可能になりました。
records
プロパティは Server Component から直接注入される 点がポイントで、SSRとCSRの境界をまたいだ一貫したメニュー表示が可能になりました。
責務分離の明確化(まとめ)
観点 | 旧構成 | 新構成 |
---|---|---|
データ取得 | AppSidebar 内でモック呼び出し | layout.tsx 側(RSC)で取得・注入 |
権限制御 | 部分的(モック依存) | RBACに完全対応 |
hidden対応 | 未実装 | .filter((r) => !r.hidden) で除外 |
表示責務 | データ取得と混在 | 表示専用に一本化 |
これにより、Sidebar は UI レイヤーとしての純粋性を取り戻し、
サーバ側のメニューキャッシュ機構 (
RBAC 判定 (
サーバ側のメニューキャッシュ機構 (
fetchMenusForDepartment
) とRBAC 判定 (
filterMenuRecordsByPriority
) の双方が連携する堅牢な構成となりました。次の章では、これらの Sidebar 構成を支える Guard 判定側(
guard.ssr.ts
)の更新内容を解説します。6. Guard処理の刷新 ─ DBメニューに基づく認可判定
この章では、ページアクセスの認可判定を司る Guard 処理を刷新し、
DB上のメニュー構成(Menu + DepartmentMenu)に基づくロジック へと移行した内容を解説します。
これにより、画面遷移の認可判定と Sidebar 表示が完全に一致する構成となります。
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 の新設
新設された
指定された部署IDに基づき、
DB上の最新メニュー構成を取得し、ページURLごとに RBAC 判定を行います。
decideGuardAsync()
は、指定された部署IDに基づき、
fetchMenusForDepartment()
を呼び出してDB上の最新メニュー構成を取得し、ページURLごとに RBAC 判定を行います。
txt
1decideGuardAsync()
2 ↓
3fetchMenusForDepartment(departmentId)
4 ↓
5buildMenuIndex() + pickBestMatch()
6 ↓
7computeRequiredPriority()
8 ↓
9ユーザーの rolePriority と比較 → 判定結果を返す
従来の
残しつつも「空配列を返す退避用」として実質的に非推奨化されています。
decideGuard()
はモックデータ専用だったため、残しつつも「空配列を返す退避用」として実質的に非推奨化されています。
これにより、現実のメニュー構成を唯一の認可基盤とする設計 が成立しました。
guardHrefOrRedirect の刷新
サーバーサイドでの実行ポイント
DB版の
これにより、ページアクセス時に部署別メニューのしきい値が正しく適用されます。
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判定統一など)にも対応しやすくなっています。
メニュー構成の差異に依存しない共通判定基盤 が実現され、
今後の拡張(Server Action 連携・API判定統一など)にも対応しやすくなっています。
改修効果のまとめ
観点 | 旧構成 | 新構成 |
---|---|---|
データソース | モック(INITIAL_MENU_RECORDS ) | DB(Menu + DepartmentMenu ) |
認可基準 | 静的 | 部署単位で動的 |
判定処理 | 同期関数 | 非同期(Prisma対応) |
UI同期 | Sidebar と乖離 | Sidebar と完全一致 |
再利用性 | ページ限定 | SSR / API / Action に拡張可能 |
この章で、ガード処理は DBメニューを唯一の信頼ソース(Single Source of Truth) として再構築されました。
これにより、Sidebar・Guard・RBAC の3要素が統一基盤で連携し、
DELOGs の管理画面全体が一貫した権限制御の下で動作するようになりました。
これにより、Sidebar・Guard・RBAC の3要素が統一基盤で連携し、
DELOGs の管理画面全体が一貫した権限制御の下で動作するようになりました。
7. /masters/menus ページのリニューアル
この章では、部署ごとのメニュー可視制御と並び順変更に対応するため、
対象ファイルは次の4つです:
/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) | 可視/非表示トグル |
movingIds | Set<string> | 並び替え中の行ID集合 |
togglingIds | Set<string> | 可視切り替え中の行ID集合 |
pendingVisible | Map<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の行はスイッチを常に無効化するようにしています。
また、誤操作防止のため「lockHiddenOverride」がtrueの行はスイッチを常に無効化するようにしています。
対応箇所 | 内容 | 改善点 |
---|---|---|
順序列 | <LoaderCircle /> を挿入 | 並べ替え中のスピナー表示 |
可視列 | pendingVisible / togglingIds を反映 | 切替中のチラつきを防止 |
lockHiddenOverride | Tooltip付きでトグル禁止 | 誤って非表示にできない設計 |
StatusFilter | Popover+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}
一覧テーブル全体を
これにより、メニュー一覧も他マスタと同様のUI・機能(検索・ソート・CSV出力など)を共有できるようになりました。
Datagrid
系コンポーネントへ移行しました。これにより、メニュー一覧も他マスタと同様のUI・機能(検索・ソート・CSV出力など)を共有できるようになりました。
主要変更 | 内容 | 効果 |
---|---|---|
DatagridToolbar / Summary / Pagination の採用 | 一覧系UIの共通化 | 操作性・デザイン統一 |
moveMenuOrderAction / toggleMenuHiddenAction | サーバーアクション連携 | DBへ即時反映 |
movingIds / togglingIds / pendingVisible | 行単位の進行状態管理 | スムーズなUX |
attachEffectiveMinPriority | しきい値の継承表示 | RBACとの整合性を保持 |
CSV出力 | 表示列のみ動的生成 | 一覧の再利用性向上 |
この改修で、可視切り替えや順序変更がサーバー反映されるまでの 一瞬のラグをUIで自然に吸収できるようになりました。
また、一覧の構造は他のマスタ系ページ(roles, users等)と完全に統一されています。
また、一覧の構造は他のマスタ系ページ(roles, users等)と完全に統一されています。
data-table.tsx
で利用しているsrc/components/datagrid/datagrid-summary.tsx
(実行中のフィルタの表示)とsrc/components/datagrid/datagrid-toolbar.tsx
(キーワード検索等のツール)について、若干の変更を行なっています。これについては、9章で記載します。この章のまとめ
項目 | Before | After |
---|---|---|
データ取得 | モック (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側では
期待される最終状態 を描画しつつ、
pendingVisible
と togglingIds
を併用。期待される最終状態 を描画しつつ、
router.refresh()
により実データ確定後に自動で処理中解除されます。兄弟間の順序入れ替え:moveMenuOrderAction(displayId, "up" | "down")
- 対象スコープ:同一
parentId
で テンプレ isActive=true の兄弟のみ。 - effectiveOrder:
DepartmentMenu.sortOrder ?? Menu.sortOrder
を採用して並べ替え。 - 端っこ判定:先頭で「up」、末尾で「down」は 何もしない(okで返す)。
- 2件swap:
$transaction
で A/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
/true
の 2値運用 で「テンプレ準拠か否か」を明確化。 - 整合性:順序入れ替えは 2件トランザクション で不整合を回避。
- UX:
pendingVisible
/togglingIds
/movingIds
により 操作→反映 の間を滑らかに。 - キャッシュ:部署タグ単位の
revalidate
で 局所的・即時 更新を実現。
以上で、可視/順序の変更は 部署単位の上書き としてDBへ安全に反映され、
キャッシュ破棄を通じて一覧・サイドバー・ガード判定にも 即座に反映 されます。
キャッシュ破棄を通じて一覧・サイドバー・ガード判定にも 即座に反映 されます。
9. DataTable構成の改良ポイント
この章では、
「情報の過不足」と「検索の分かりやすさ」 の両面で使いやすさを高めた内容を解説します。
対象は以下の2ファイルです。
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}
もうひとつの改善点は、検索欄のプレースホルダに
「どの項目が検索対象なのか」を明示できるようにしたことです。
「どの項目が検索対象なのか」を明示できるようにしたことです。
これまでの
複数カラム検索(例:表示ID・タイトル・Path など)の対象が不明瞭でした。
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()
を実行します。key
と parentKey
によって親子関係を表現し、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運用へと近づいた段階です。
これにより、モックから脱却し、実際の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を学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #9 後編] 部署別ロール対応 ─ プロフィール管理の改修
DepartmentRole導入に伴い、プロフィール管理で「実効ロール」を参照するように修正と一部ついでの変更
2025/10/8公開
![[管理画面フォーマット開発編 #9 後編] 部署別ロール対応 ─ プロフィール管理の改修のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-profile%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修
DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正
2025/10/5公開
![[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-users%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
2025/10/2公開
![[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計
グローバルで一貫したRoleテーブルを保ちながら、部署ごとにロールをカスタマイズするために「DepartmentRole」テーブルを新設
2025/9/29公開
![[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-db%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携する
ユーザ一覧表示・新規登録・編集フォームをDBと連動させ、ユーザデータを操作できる形へ
2025/9/28公開
![[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-users%2Fhero-thumbnail.jpg&w=1200&q=75)