![[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-ui%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #8 後編部署別ロール ─ 管理UIとServer Action実装
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
初回公開日
最終更新日
0. はじめに(後編の狙い)
【管理画面フォーマット開発編 #8 前編】 部署別ロール ─ DepartmentRoleテーブル導入とDB設計 では DepartmentRoleテーブルの設計・Prismaモデル更新・マイグレーション を中心に基盤を整備しました。
後編では、これを実際の管理画面から操作できるように Server Action と管理UI を構築していきます。
後編では、これを実際の管理画面から操作できるように Server Action と管理UI を構築していきます。
本編の位置づけ
本記事の位置づけを、前編との関係も含めて整理すると次の通りです。
区分 | 前編(DB/Prisma) | 後編(本記事:UI/SA) |
---|---|---|
スキーマ | DepartmentRoleテーブルの導入、UserへのXOR制約 | Prisma Clientを利用して実際のデータを操作 |
制約 | priority制約、override/customのXOR、Userの整合性 | UI/Server Actionで入力検証を二重に担保 |
実効ロール | effective-role.ts による合成実装 | getUserSnapshot 内部に反映済み、UI/ガードは変更不要 |
管理機能 | - | 新規登録・更新・一覧画面の実装 |
本編の狙い
後編で実現するのは「DBに存在するDepartmentRoleを、UIから安全に操作できる状態にすること」です。
単にCRUDを作るだけではなく、以下のような観点を盛り込みます。
単にCRUDを作るだけではなく、以下のような観点を盛り込みます。
観点 | 説明 |
---|---|
安全性 | DB制約に加え、Server ActionとUIフォームの二重バリデーションで担保する |
検証性 | 新規 → 更新 → 一覧の順で作ることで、テストデータを活用しやすくする |
一貫性 | 既存のRole管理UIと同じフォーム構造・UXを踏襲する |
RBACとの整合性 | guardHrefOrRedirectやmenu.rbacの既存仕組みはそのまま利用する |
記事の進め方
本記事では、以下の順序で解説を進めます。
- 新規登録:override/customを選択可能なフォームとServer Actionを実装
- 更新:詳細画面を開き、条件に応じて編集範囲を切り替え
- 一覧:DepartmentRoleを検索・ソート可能に表示
それぞれの章で、Server Action → UI → 動作確認の流れを繰り返すことで、最終的に運用可能な管理UIを完成させます。
技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.x | スキーマ定義と実行時バリデーション |
本記事では、前回の記事 【管理画面フォーマット開発編 #8 前編】部署別ロール ─ DepartmentRoleテーブル導入とDB設計 までのソースコードを引き継いで追加・編集していきます。
1. 新規登録(DepartmentRole.create)
この章では、テナント側(部署)のオペレータが 独自ロール(DepartmentRole の custom モード)を新規作成 できるようにします。
前編で実装した DB 制約(
前編で実装した DB 制約(
priority ≤ 99
、override/custom の XOR)を前提に、Zod → Server Action → UI フォーム → 画面の順に組み立てます。補足:
新規登録はテナント独自ロール(custom)専用です。
既存のグローバル Role の「名称/色」だけを部署で上書きしたい場合(override)は 編集章 で扱います。
新規登録はテナント独自ロール(custom)専用です。
既存のグローバル Role の「名称/色」だけを部署で上書きしたい場合(override)は 編集章 で扱います。
txt
1# 1章のアウトライン(文字図)
2
3[要件整理]
4 └─ 新規 = DepartmentRole.custom のみ
5 ・code/name/priority/can* 必須
6 ・priority ≤ 99
7 ・departmentId はログイン中ユーザの所属から自動付与
8
9[Zod スキーマ]
10 └─ src/lib/department-roles/schema.ts
11 ・create 用の単一スキーマ(custom 固定)
12 ・共通フィールドは一意に定義
13
14[Server Action]
15 └─ src/app/(protected)/masters/roles/_actions/department-roles/create.ts
16 入力検証 → 部署解決 → Prisma.create → displayId を返す
17 ※ 重複 code / priority 違反 / その他 DB エラーの分岐
18
19[UI フォーム]
20 └─ src/components/department-roles/department-role-form.tsx
21 RHF + zodResolver / CreateForm だけ
22 code/name/priority/badgeColor/isEnabled/can* を入力
23
24[ページ]
25 ├─ src/app/(protected)/masters/roles/new/page.tsx(SSR ガード+枠)
26 └─ src/app/(protected)/masters/roles/new/client.tsx(SA 呼び出し+トースト)
要件整理(custom 固定)
新規登録で扱うのは DepartmentRole の custom モードに限定します。部署ごとの独自ロールを作るユースケースに絞り、override(見た目上書き)は編集でカバーします。
項目 | 仕様(custom) |
---|---|
作成対象 | DepartmentRole (custom モード) |
必須フィールド | code (部署内一意), name , priority (≤ 99), canEditData , canDownloadData |
任意フィールド | badgeColor , remarks , isEnabled (初期 true) |
禁止/自動付与 | roleId は常に NULL 、departmentId は ログイン中ユーザの所属部署を自動付与 |
制約(DB 側) | (departmentId, code) 一意 / priority ≤ 99 / custom と override の XOR |
エラーハンドリング | code 重複 / priority 違反 / その他 DB 例外 |
以降では、この要件を Zod / Server Action / UI の順に落とし込みます。
Zod スキーマと型(custom 用の単一スキーマ)
フィールドごとに 一意の定義 を作成し、フォーム・Server Action 双方で同じ定義を使い回します。
ここでは新規(custom 固定)だけを扱うため、 単一スキーマ で十分です(override は編集章で別途扱う)。
ここでは新規(custom 固定)だけを扱うため、 単一スキーマ で十分です(override は編集章で別途扱う)。
ts
1// src/lib/department-roles/schema.ts
2import { z } from "zod";
3
4/** 入力ルール(数字は定数化すると見直しやすい) */
5export const DR_CODE_MAX = 50 as const;
6export const DR_NAME_MAX = 100 as const;
7export const DR_PRIORITY_MAX = 99 as const;
8export const DR_REMARKS_MAX = 255 as const;
9
10/** 共通フィールド定義(単一の定義に集約) */
11const codeSchema = z
12 .string()
13 .regex(
14 /^[A-Z][A-Z0-9_]*$/,
15 "大文字英字・数字・アンダースコアのみ使用できます",
16 )
17 .min(2, "2文字以上で入力してください")
18 .max(DR_CODE_MAX, `${DR_CODE_MAX}文字以内で入力してください`);
19
20const nameSchema = z
21 .string()
22 .min(1, "表示名を入力してください")
23 .max(DR_NAME_MAX, `${DR_NAME_MAX}文字以内で入力してください`);
24
25const prioritySchema = z.coerce
26 .number()
27 .int("整数で入力してください")
28 .min(0, "0以上で入力してください")
29 .max(DR_PRIORITY_MAX, `${DR_PRIORITY_MAX}以下で入力してください`);
30
31const badgeColorSchema = z
32 .string()
33 .regex(
34 /^#([0-9A-Fa-f]{6})$/,
35 "カラーコードは #RRGGBB の形式で入力してください",
36 )
37 .optional();
38
39const remarksSchema = z
40 .string()
41 .max(DR_REMARKS_MAX, `${DR_REMARKS_MAX}文字以内で入力してください`)
42 .optional();
43
44/** ── 新規(custom 固定): roleId は扱わない ── */
45export const departmentRoleCreateSchema = z.object({
46 code: codeSchema,
47 name: nameSchema,
48 priority: prioritySchema, // DB 側でも ≤99 を保証
49 badgeColor: badgeColorSchema,
50 isEnabled: z.boolean().default(true),
51 canDownloadData: z.boolean(),
52 canEditData: z.boolean(),
53 remarks: remarksSchema,
54});
55
56/** Zod から型を派生(唯一の真実) */
57export type DepartmentRoleCreateValues = z.infer<
58 typeof departmentRoleCreateSchema
59>;
60
61/** 追加:RHF の「入力型」(coerce の前提で priority は unknown を許容) */
62export type DepartmentRoleCreateInput = z.input<
63 typeof departmentRoleCreateSchema
64>;
priority
はz.coerce.number()
を使い、フォームからの文字列入力にも自然に対応します。- DB の
priority ≤ 99
と 二重でガード(UI/SA と DB)。 roleId
は custom では不要のためスキーマに含めません(=常にNULL
)。
Server Action:create(部署解決 → 作成 → displayId 返却)
ここでは、共通仕様に合わせて
lookupSessionFromCookie
で認証 → getUserSnapshot
で権限確認 → 所属部署解決 → Zod 検証 → DB 登録 の順に実装します。ts
1// src/app/_actions/department-roles/create.ts
2"use server";
3
4import "server-only";
5import { prisma } from "@/lib/database";
6import { lookupSessionFromCookie } from "@/lib/auth/session";
7import { getUserSnapshot } from "@/lib/auth/user-snapshot";
8import {
9 departmentRoleCreateSchema,
10 type DepartmentRoleCreateValues,
11} from "@/lib/department-roles/schema";
12
13/** 共通仕様に合わせた返却型(将来は共通モジュールへ移動して import 推奨) */
14type ActionResult = { ok: true } | { ok: false; message: string };
15
16/** 部署ロール(custom)新規作成 */
17export async function createDepartmentRole(
18 values: DepartmentRoleCreateValues,
19): Promise<ActionResult> {
20 // 1) 認証
21 const ses = await lookupSessionFromCookie();
22 if (!ses.ok) return { ok: false, message: "認証が必要です。" };
23
24 // 2) 権限(書き込み権限が必要)
25 const snap = await getUserSnapshot(ses.userId);
26 if (!snap || !snap.canEditData) {
27 return { ok: false, message: "この操作を行う権限がありません。" };
28 }
29
30 // 3) 所属部署の解決
31 const me = await prisma.user.findUnique({
32 where: { id: ses.userId },
33 select: { departmentId: true },
34 });
35 if (!me?.departmentId) {
36 return { ok: false, message: "部署情報を取得できませんでした。" };
37 }
38
39 // 4) サーバ側の最終入力検証(詳細なフィールドエラーは UI に委譲)
40 const parsed = departmentRoleCreateSchema.safeParse(values);
41 if (!parsed.success) {
42 return { ok: false, message: "入力内容を確認してください。" };
43 }
44 const {
45 code,
46 name,
47 priority,
48 badgeColor,
49 isEnabled,
50 canDownloadData,
51 canEditData,
52 remarks,
53 } = parsed.data;
54
55 // 5) 部署内コード重複チェック(@@unique([departmentId, code]) の前に早期弾く)
56 const exists = await prisma.departmentRole.findFirst({
57 where: { departmentId: me.departmentId, code },
58 select: { id: true },
59 });
60 if (exists) {
61 return { ok: false, message: "このコードは既に使用されています。" };
62 }
63
64 // 6) 登録(custom 固定: roleId は常に NULL)
65 try {
66 await prisma.departmentRole.create({
67 data: {
68 departmentId: me.departmentId,
69 code,
70 name,
71 priority,
72 badgeColor: badgeColor ?? null,
73 isEnabled,
74 canDownloadData,
75 canEditData,
76 isSystem: false, // custom 作成は部署ローカルなので system 扱いしない
77 remarks: remarks ?? null,
78 },
79 select: { id: true }, // 返却しないが SELECT は最小限に
80 });
81 } catch (e) {
82 // ユニーク制約 or CHECK 失敗などの一般化したエラーメッセージ
83 console.error("[createDepartmentRole] DB create failed:", e);
84 return { ok: false, message: "ロールの登録に失敗しました。" };
85 }
86
87 // 7) 完了
88 return { ok: true };
89}
- 認証/権限/部署解決は既存の共通ユーティリティに準拠。
- Zod での最終検証(
priority ≤ 99
等)は DB 制約と二重化 して安全性を担保。 - 返却は共通の
ActionResult
で統一し、詳細エラーは UI の入力エラー表示(Zod)側を基本とする方針に合わせています。 - ここで
roleId
は一切扱わず custom 固定としています(override は「編集」で対応)。
UI:新規フォーム(custom 固定)
フォームは既存の Users と同じパターンに合わせ、 CreateForm 単体 で構成します。
RHF +
RHF +
zodResolver
、小さなフィールド群は 同一ファイル に定義し、一意の Zod 定義に準拠します。tsx
1// src/components/department-roles/department-role-form.tsx
2"use client";
3
4import * as React from "react";
5import { useForm } from "react-hook-form";
6import { zodResolver } from "@hookform/resolvers/zod";
7
8import {
9 departmentRoleCreateSchema,
10 type DepartmentRoleCreateInput,
11 type DepartmentRoleCreateValues,
12 DR_PRIORITY_MAX,
13} from "@/lib/department-roles/schema";
14
15import {
16 Form,
17 FormField,
18 FormItem,
19 FormLabel,
20 FormControl,
21 FormMessage,
22 FormDescription,
23} from "@/components/ui/form";
24import { Input } from "@/components/ui/input";
25import { Switch } from "@/components/ui/switch";
26import { Button } from "@/components/ui/button";
27import { Card, CardContent, CardFooter } from "@/components/ui/card";
28
29/* =========================
30 公開インターフェース(型)
31 ========================= */
32
33type BaseProps = {
34 onCancel?: () => void;
35};
36
37type CreateProps = BaseProps & {
38 mode: "create";
39 onSubmit: (values: DepartmentRoleCreateValues) => void;
40};
41
42type Props = CreateProps; // まずは新規専用。編集は #2 章で追加予定
43
44/* =========================
45 エクスポート本体
46 ========================= */
47
48export default function DepartmentRoleForm(props: Props) {
49 // 将来 edit 追加時も Users と同様に分岐させる
50 return <CreateForm {...props} />;
51}
52
53/* =========================
54 Create(新規)フォーム
55 ========================= */
56
57function CreateForm({ onSubmit, onCancel }: CreateProps) {
58 // ▼ Users と同じ構成。priority が coerce なので 3 ジェネリクスで厳密化
59 const form = useForm<
60 DepartmentRoleCreateInput,
61 undefined,
62 DepartmentRoleCreateValues
63 >({
64 resolver: zodResolver(departmentRoleCreateSchema),
65 defaultValues: {
66 code: "",
67 name: "",
68 priority: 0,
69 badgeColor: "#666666",
70 isEnabled: true,
71 canDownloadData: false,
72 canEditData: false,
73 remarks: "",
74 },
75 mode: "onBlur",
76 });
77
78 const handleSubmit = form.handleSubmit(onSubmit);
79
80 return (
81 <Form {...form}>
82 <form data-testid="department-role-form-create" onSubmit={handleSubmit}>
83 <Card className="w-full rounded-md">
84 <CardContent className="space-y-6 pt-1">
85 <CodeField />
86 <NameField />
87 <PriorityField />
88 <BadgeColorField />
89 <IsEnabledField />
90 <CanDownloadField />
91 <CanEditField />
92 <RemarksField />
93 </CardContent>
94
95 <CardFooter className="mt-4 flex gap-2">
96 <Button
97 type="button"
98 variant="outline"
99 onClick={onCancel}
100 data-testid="cancel-btn"
101 className="cursor-pointer"
102 >
103 キャンセル
104 </Button>
105 <Button
106 type="submit"
107 data-testid="submit-create"
108 disabled={form.formState.isSubmitting}
109 className="cursor-pointer"
110 >
111 登録する
112 </Button>
113 </CardFooter>
114 </Card>
115 </form>
116 </Form>
117 );
118}
119
120/* =========================
121 小さなフィールド群(同ファイル内)
122 - Users と同じ分割方針
123 - FormField は RHF context から control を取得
124 ========================= */
125
126// コード
127function CodeField() {
128 return (
129 <FormField
130 name="code"
131 render={({ field }) => (
132 <FormItem>
133 <FormLabel className="font-semibold">コード *</FormLabel>
134 <FormControl>
135 <Input
136 {...field}
137 placeholder="ANALYST"
138 aria-label="コード"
139 autoComplete="off"
140 data-testid="code"
141 />
142 </FormControl>
143 <FormMessage data-testid="code-error" />
144 </FormItem>
145 )}
146 />
147 );
148}
149
150// 表示名
151function NameField() {
152 return (
153 <FormField
154 name="name"
155 render={({ field }) => (
156 <FormItem>
157 <FormLabel className="font-semibold">表示名 *</FormLabel>
158 <FormControl>
159 <Input
160 {...field}
161 placeholder="分析担当"
162 aria-label="表示名"
163 autoComplete="off"
164 data-testid="name"
165 />
166 </FormControl>
167 <FormMessage data-testid="name-error" />
168 </FormItem>
169 )}
170 />
171 );
172}
173
174// 優先度(coerce 対応:入力中は "" を許容 → Zod で数値に確定)
175function PriorityField() {
176 return (
177 <FormField
178 name="priority"
179 render={({ field }) => (
180 <FormItem>
181 <FormLabel className="font-semibold">優先度 *</FormLabel>
182 <FormControl>
183 <Input
184 type="number"
185 inputMode="numeric"
186 min={0}
187 max={DR_PRIORITY_MAX}
188 step={1}
189 {...field}
190 value={
191 field.value === undefined || field.value === null
192 ? ""
193 : String(field.value)
194 }
195 onChange={(e) => {
196 const v = e.target.value;
197 // 空文字は許容(Zod が最終確定)
198 field.onChange(v === "" ? "" : v);
199 }}
200 placeholder="20"
201 aria-label="優先度"
202 data-testid="priority"
203 />
204 </FormControl>
205 <FormDescription className="text-xs">
206 0〜{DR_PRIORITY_MAX}{" "}
207 の整数で入力してください(99以下で部署ロール)。
208 </FormDescription>
209 <FormMessage data-testid="priority-error" />
210 </FormItem>
211 )}
212 />
213 );
214}
215
216// バッジ色
217function BadgeColorField() {
218 return (
219 <FormField
220 name="badgeColor"
221 render={({ field }) => (
222 <FormItem>
223 <FormLabel className="font-semibold">バッジ色 *</FormLabel>
224 <FormControl>
225 <Input
226 type="color"
227 {...field}
228 aria-label="バッジ色"
229 data-testid="badgeColor"
230 className="cursor-pointer"
231 />
232 </FormControl>
233 <FormMessage data-testid="badgeColor-error" />
234 </FormItem>
235 )}
236 />
237 );
238}
239
240// 有効フラグ
241function IsEnabledField() {
242 return (
243 <FormField
244 name="isEnabled"
245 render={({ field }) => (
246 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
247 <div className="space-y-0.5">
248 <FormLabel className="font-semibold">有効 *</FormLabel>
249 <FormDescription>ONで有効/OFFで無効</FormDescription>
250 </div>
251 <FormControl>
252 <Switch
253 name={field.name}
254 checked={Boolean(field.value)}
255 onCheckedChange={field.onChange}
256 aria-label="有効"
257 data-testid="isEnabled"
258 />
259 </FormControl>
260 <FormMessage data-testid="isEnabled-error" />
261 </FormItem>
262 )}
263 />
264 );
265}
266
267// データDL可
268function CanDownloadField() {
269 return (
270 <FormField
271 name="canDownloadData"
272 render={({ field }) => (
273 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
274 <FormLabel className="font-semibold">データDL可 *</FormLabel>
275 <FormControl>
276 <Switch
277 name={field.name}
278 checked={Boolean(field.value)}
279 onCheckedChange={field.onChange}
280 aria-label="データDL可"
281 data-testid="canDownloadData"
282 />
283 </FormControl>
284 <FormMessage data-testid="canDownloadData-error" />
285 </FormItem>
286 )}
287 />
288 );
289}
290
291// データ編集可
292function CanEditField() {
293 return (
294 <FormField
295 name="canEditData"
296 render={({ field }) => (
297 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
298 <FormLabel className="font-semibold">データ編集可 *</FormLabel>
299 <FormControl>
300 <Switch
301 name={field.name}
302 checked={Boolean(field.value)}
303 onCheckedChange={field.onChange}
304 aria-label="データ編集可"
305 data-testid="canEditData"
306 />
307 </FormControl>
308 <FormMessage data-testid="canEditData-error" />
309 </FormItem>
310 )}
311 />
312 );
313}
314
315// 備考
316function RemarksField() {
317 return (
318 <FormField
319 name="remarks"
320 render={({ field }) => (
321 <FormItem>
322 <FormLabel className="font-semibold">備考</FormLabel>
323 <FormControl>
324 <Input
325 {...field}
326 placeholder="メモなど"
327 aria-label="備考"
328 autoComplete="off"
329 data-testid="remarks"
330 />
331 </FormControl>
332 <FormMessage data-testid="remarks-error" />
333 </FormItem>
334 )}
335 />
336 );
337}
useForm<入力型, undefined, 変換後型>
の 3 つ目の型引数はhandleSubmit
に渡る値の型。
ここをDepartmentRoleCreateValues
にすることで、onSubmit
ではpriority: number
に確定した値を安全に扱えます。priority
フィールドは Users でやっているのと同じく、空文字を許容 → Zod 側で必須/数値化 の流れにしています(入力中のチラつき防止)。
画面:/masters/roles/new(SSR ガード+SA 呼び出し)
この節では、部署ロール(DepartmentRole/custom)を新規作成する画面を
/masters/roles/new
に実装します。SSR でのアクセスガード(guardHrefOrRedirect
)を通し、クライアント側で Server Action createDepartmentRole
を呼び出して登録します。フォームは前節で作った DepartmentRoleForm
をそのまま用い、成功時は一覧 /masters/roles
に戻る一貫した UX にします。要素 | ポイント |
---|---|
ルート | /masters/roles/new (テナント視点の「ロール」は Role/DepartmentRole の混在で一本化) |
ガード | SSR で guardHrefOrRedirect("/masters/roles/new", "/") |
フォーム | DepartmentRoleForm (Users と同じ構成規約・any 不使用) |
登録処理 | createDepartmentRole(values) を呼び、成功で Toast → 一覧へ遷移 |
tsx
1// src/app/(protected)/masters/roles/new/page.tsx
2import type { Metadata } from "next";
3import { redirect } from "next/navigation";
4import {
5 Breadcrumb,
6 BreadcrumbItem,
7 BreadcrumbLink,
8 BreadcrumbList,
9 BreadcrumbPage,
10 BreadcrumbSeparator,
11} from "@/components/ui/breadcrumb";
12import { Separator } from "@/components/ui/separator";
13import { SidebarTrigger } from "@/components/ui/sidebar";
14import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
15import Client from "./client";
16
17export const metadata: Metadata = {
18 title: "ロール新規登録(部署ローカル)",
19 description:
20 "部署ロール(DepartmentRole/custom)を新規作成。shadcn/ui + RHF + Zod + Server Action",
21};
22
23export default async function Page() {
24 // ★ SSR ガードで viewer を取得
25 const viewer = await guardHrefOrRedirect("/masters/roles/new", "/");
26
27 // ★ 編集権限がなければ強制リダイレクト
28 if (!viewer.canEditData) {
29 // guardHrefOrRedirect は内部で redirect() を呼べるので
30 // 明示的に使うなら:
31 return redirect("/");
32 }
33 return (
34 <>
35 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
36 <div className="flex items-center gap-2 px-4">
37 <SidebarTrigger className="-ml-1" />
38 <Separator
39 orientation="vertical"
40 className="mr-2 data-[orientation=vertical]:h-4"
41 />
42 <Breadcrumb>
43 <BreadcrumbList>
44 <BreadcrumbItem className="hidden md:block">
45 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
46 </BreadcrumbItem>
47 <BreadcrumbSeparator className="hidden md:block" />
48 <BreadcrumbItem className="hidden md:block">
49 <BreadcrumbLink href="/masters/roles">
50 ロール管理
51 </BreadcrumbLink>
52 </BreadcrumbItem>
53 <BreadcrumbSeparator className="hidden md:block" />
54 <BreadcrumbItem>
55 <BreadcrumbPage>ロール新規登録</BreadcrumbPage>
56 </BreadcrumbItem>
57 </BreadcrumbList>
58 </Breadcrumb>
59 </div>
60 </header>
61
62 <div className="max-w-xl p-4 pt-0">
63 <Client />
64 </div>
65 </>
66 );
67}
- 画面ルーティングは
/masters/roles/new
に統一し、テナント視点での「ロール」を一本化します。 - SSR 側で
guardHrefOrRedirect
を実行して、未ログイン・権限不足時に即座に退避します。 - このページでは DB からのロール読込は不要(custom 固定の新規)なので、
Client
の props は空です。
tsx
1// src/app/(protected)/masters/roles/new/client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { toast } from "sonner";
6
7import DepartmentRoleForm from "@/components/department-roles/department-role-form";
8import type { DepartmentRoleCreateValues } from "@/lib/department-roles/schema";
9import { createDepartmentRole } from "@/app/_actions/department-roles/create";
10
11export default function NewDepartmentRoleClient() {
12 const router = useRouter();
13
14 const handleSubmit = async (values: DepartmentRoleCreateValues) => {
15 const res = await createDepartmentRole(values);
16
17 if (res.ok) {
18 toast.success("ロールを作成しました", {
19 description: [
20 `コード: ${values.code}`,
21 `表示名: ${values.name}`,
22 `優先度: ${values.priority}`,
23 `DL: ${values.canDownloadData ? "可" : "不可"}`,
24 `編集: ${values.canEditData ? "可" : "不可"}`,
25 ].join(" / "),
26 duration: 3000,
27 });
28 router.push("/masters/roles");
29 router.refresh();
30 return;
31 }
32
33 // 失敗時:詳細はクライアント検証に委譲し、汎用メッセージを表示
34 toast.error(
35 res.message ?? "登録に失敗しました。入力内容を確認してください。",
36 {
37 duration: 3500,
38 },
39 );
40 };
41
42 return (
43 <DepartmentRoleForm
44 mode="create"
45 onSubmit={handleSubmit}
46 onCancel={() => history.back()}
47 />
48 );
49}
- フォームは
DepartmentRoleForm
(Users と同設計)を再利用し、onSubmit
で Server Action を呼び出します。 - 成功時:トースト →
/masters/roles
に遷移 →router.refresh()
で一覧再描画。 - 失敗時:クライアント側のフィールド別メッセージは Zod に委譲し、トーストは汎用メッセージに統一します。
any
は未使用、型はDepartmentRoleCreateValues
のみを受け取り、プロジェクトの型規約を順守しています。
2. 更新(DepartmentRole.update / Override 作成・編集)
この章では、ロールの編集ページ
テナント視点のロールは「
/masters/roles/[displayId]
を実装します。テナント視点のロールは「
Role
行(ベース)」と「DepartmentRole
行(部署ローカル)」が 同一の一覧 に混在します。編集画面は次の2パターンに対応します。対象 | 画面に来る displayId | 編集内容 | 保存時の処理 |
---|---|---|---|
custom(部署ローカル独自ロール) | DRxxxxxxxx | code / name / priority / can* / badgeColor / isEnabled / remarks | DepartmentRole の該当レコードを 更新 |
override(ベース Role の上書き) | RLxxxxxxxx (初回: DR 不在)/ DRxxxxxxxx (既に DR 有り) | nameOverride / badgeColorOverride / isEnabled (※ 権限/priority は Role と同値) | なければ作成(roleId あり) / あれば 更新。削除=「DR を削除して Role 素の状態に戻す」 |
初期状態では、ベース
Role
の override が存在しない 可能性があります。この場合、編集ページで override を新規作成 できるようにします。要件整理(custom / override の編集ルール)
編集では 2 つの型 を明確に分けます。
DepartmentRole
の roleId
有無で判定可能ですが、UI からは判別フラグを渡すと実装が単純です。種別 | 入力項目 | 備考 |
---|---|---|
custom | displayId, code, name, priority, canDownloadData, canEditData, badgeColor, isEnabled, remarks | 既存の DR を編集。priority ≤ 99 。 |
override | displayId?(DR の場合), roleDisplayId?(RL の場合), nameOverride?, badgeColorOverride?, isEnabled | 初回は DR が無いので roleDisplayId 起点で 新規作成。isEnabled は部署での有効/無効。 |
- 削除
- custom: 「論理削除」が無い設計なら DELETE。復活は「新規作成」で代替。
- override: DR を物理削除してベース Role に戻します。
Zod スキーマ(編集用の識別ユニオン)
- 1章と同じ方針で、共通フィールドは単一定義に集約。
- 編集は 識別子
kind: "custom" | "override"
を持つユニオンで表現します。 - RHF と組み合わせるため、
input
/values
(確定型)を明示します。
ts
1// src/lib/department-roles/schema.ts (末尾に追加)
2
3// ─ 1章で作成した内容を省略
4
5/** ── 更新(識別ユニオン) ── */
6const customEdit = z.object({
7 kind: z.literal("custom"),
8 displayId: z.string().min(1, "表示IDの取得に失敗しました"),
9 code: codeSchema,
10 name: nameSchema,
11 priority: prioritySchema,
12 badgeColor: badgeColorSchema,
13 isEnabled: z.boolean(),
14 canDownloadData: z.boolean(),
15 canEditData: z.boolean(),
16 remarks: remarksSchema,
17});
18
19const overrideEditExisting = z.object({
20 kind: z.literal("override"),
21 // 既存 override を編集する場合
22 displayId: z.string().min(1, "表示IDの取得に失敗しました").optional(),
23 // 初回(DRなし)の場合は Role 側の displayId を持つ
24 roleDisplayId: z.string().min(1, "Role の表示IDが必要です").optional(),
25 nameOverride: z.string().min(0).max(DR_NAME_MAX).optional(),
26 badgeColorOverride: badgeColorSchema,
27 isEnabled: z.boolean(),
28});
29
30export const departmentRoleUpdateSchema = z.union([customEdit, overrideEditExisting]);
31
32export type DepartmentRoleUpdateValues = z.infer<typeof departmentRoleUpdateSchema>;
33export type DepartmentRoleUpdateInput = z.input<typeof departmentRoleUpdateSchema>;
- custom は 1章の新規と同じ項目。
- override は
nameOverride / badgeColorOverride / isEnabled
のみ編集可能。 - override の 初回作成 にも対応するため、
displayId
(DR 側)またはroleDisplayId
(RL 側)のどちらかを受け取れるようにしています(optional()
を許容)。実装では どちらか必須 をアプリ側で担保します。
Server Action:update / delete(custom・override 兼用)
- 認証・権限・部署解決は 1章と同様。
kind
によって custom 更新 と override 更新(作成/更新) を分岐。- 削除は custom: DR 削除 / override: DR 削除で Role にロールバック 。
ts
1// src/app/_actions/department-roles/update.ts
2"use server";
3
4import "server-only";
5import { prisma } from "@/lib/database";
6import { lookupSessionFromCookie } from "@/lib/auth/session";
7import { getUserSnapshot } from "@/lib/auth/user-snapshot";
8import {
9 departmentRoleUpdateSchema,
10 type DepartmentRoleUpdateValues,
11} from "@/lib/department-roles/schema";
12
13type ActionResult = { ok: true } | { ok: false; message: string };
14
15export async function updateDepartmentRole(
16 values: DepartmentRoleUpdateValues,
17): Promise<ActionResult> {
18 // 1) 認証
19 const ses = await lookupSessionFromCookie();
20 if (!ses.ok) return { ok: false, message: "認証が必要です。" };
21
22 // 2) 権限
23 const snap = await getUserSnapshot(ses.userId);
24 if (!snap || !snap.canEditData) {
25 return { ok: false, message: "この操作を行う権限がありません。" };
26 }
27
28 // 3) 所属部署
29 const me = await prisma.user.findUnique({
30 where: { id: ses.userId },
31 select: { departmentId: true },
32 });
33 if (!me?.departmentId)
34 return { ok: false, message: "部署情報を取得できませんでした。" };
35
36 // 4) サーバ最終検証
37 const parsed = departmentRoleUpdateSchema.safeParse(values);
38 if (!parsed.success)
39 return { ok: false, message: "入力内容を確認してください。" };
40
41 const v = parsed.data;
42
43 // 5) 分岐
44 if (v.kind === "custom") {
45 // 部署内コード重複(自分以外)
46 const dup = await prisma.departmentRole.findFirst({
47 where: {
48 departmentId: me.departmentId,
49 code: v.code,
50 NOT: { displayId: v.displayId },
51 },
52 select: { id: true },
53 });
54 if (dup)
55 return { ok: false, message: "このコードは既に使用されています。" };
56
57 try {
58 await prisma.departmentRole.update({
59 where: { displayId: v.displayId },
60 data: {
61 code: v.code,
62 name: v.name,
63 priority: v.priority,
64 badgeColor: v.badgeColor ?? null,
65 isEnabled: v.isEnabled,
66 canDownloadData: v.canDownloadData,
67 canEditData: v.canEditData,
68 remarks: v.remarks ?? null,
69 },
70 });
71 } catch (e) {
72 console.error("[updateDepartmentRole/custom] DB update failed:", e);
73 return { ok: false, message: "更新に失敗しました。" };
74 }
75 return { ok: true };
76 }
77
78 // override: 既存 or 初回
79 try {
80 if (v.displayId) {
81 // 既存 override を更新
82 await prisma.departmentRole.update({
83 where: { displayId: v.displayId },
84 data: {
85 isEnabled: v.isEnabled,
86 nameOverride: v.nameOverride ?? null,
87 badgeColorOverride: v.badgeColorOverride ?? null,
88 },
89 });
90 } else if (v.roleDisplayId) {
91 // 初回作成(roleId を解決して作成)
92 const role = await prisma.role.findUnique({
93 where: { displayId: v.roleDisplayId },
94 select: { id: true },
95 });
96 if (!role)
97 return { ok: false, message: "対象ロールを取得できませんでした。" };
98
99 await prisma.departmentRole.create({
100 data: {
101 departmentId: me.departmentId,
102 roleId: role.id,
103 isEnabled: v.isEnabled,
104 nameOverride: v.nameOverride ?? null,
105 badgeColorOverride: v.badgeColorOverride ?? null,
106 },
107 });
108 } else {
109 return { ok: false, message: "表示IDの指定が不足しています。" };
110 }
111 } catch (e) {
112 console.error("[updateDepartmentRole/override] failed:", e);
113 return { ok: false, message: "更新に失敗しました。" };
114 }
115
116 return { ok: true };
117}
118
119export async function deleteDepartmentRole(
120 displayId: string,
121): Promise<ActionResult> {
122 // 1) 認証/権限
123 const ses = await lookupSessionFromCookie();
124 if (!ses.ok) return { ok: false, message: "認証が必要です。" };
125 const snap = await getUserSnapshot(ses.userId);
126 if (!snap || !snap.canEditData) {
127 return { ok: false, message: "この操作を行う権限がありません。" };
128 }
129
130 // 2) 存在確認(custom or override どちらでも可)
131 const dr = await prisma.departmentRole.findUnique({
132 where: { displayId },
133 select: { id: true, roleId: true },
134 });
135 if (!dr) return { ok: false, message: "対象が見つかりません。" };
136
137 // 3) 削除(custom も override も DELETE。override はベース Role に戻る効果)
138 try {
139 await prisma.departmentRole.delete({ where: { displayId } });
140 } catch (e) {
141 console.error("[deleteDepartmentRole] DB delete failed:", e);
142 return { ok: false, message: "削除に失敗しました。" };
143 }
144
145 return { ok: true };
146}
- custom の重複チェック は
departmentId + code
のユニーク制約に合わせて事前確認。 - override 初回 は
roleDisplayId
からroleId
を解決して DR を作成 します。 - 削除 は override/custom ともに DR を消すだけ。override の場合は自然と ベース Role の表示に戻ります。
UI:フォーム拡張(Create + Edit、識別ユニオン対応)
1章の
Users フォームと同じ構成( 小さなフィールドを関数に分割 )で、
DepartmentRoleForm
を拡張し、mode: "create" | "edit"
と kind: "custom" | "override"
を受けられるようにします。Users フォームと同じ構成( 小さなフィールドを関数に分割 )で、
any
不使用 ・zodResolver
を活用します。
また、あわせて、ロールにcanEditData
がないユーザには編集ができないようにします。tsx
1// src/components/department-roles/department-role-form.tsx
2"use client";
3
4import * as React from "react";
5import { useForm } from "react-hook-form";
6import { zodResolver } from "@hookform/resolvers/zod";
7
8import {
9 departmentRoleCreateSchema,
10 type DepartmentRoleCreateInput,
11 type DepartmentRoleCreateValues,
12 departmentRoleUpdateSchema,
13 type DepartmentRoleUpdateInput,
14 type DepartmentRoleUpdateValues,
15 DR_PRIORITY_MAX,
16} from "@/lib/department-roles/schema";
17
18import {
19 Form,
20 FormField,
21 FormItem,
22 FormLabel,
23 FormControl,
24 FormMessage,
25 FormDescription,
26} from "@/components/ui/form";
27import { Input } from "@/components/ui/input";
28import { Switch } from "@/components/ui/switch";
29import { Button } from "@/components/ui/button";
30import { Card, CardContent, CardFooter } from "@/components/ui/card";
31
32type BaseProps = {
33 onCancel?: () => void;
34 onDelete?: () => void;
35 readOnly?: boolean;
36};
37
38type CreateProps = BaseProps & {
39 mode: "create";
40 onSubmit: (values: DepartmentRoleCreateValues) => void;
41 initialValues?: never;
42};
43
44type EditProps = BaseProps & {
45 mode: "edit";
46 onSubmit: (values: DepartmentRoleUpdateValues) => void;
47 initialValues: DepartmentRoleUpdateValues;
48};
49
50type Props = CreateProps | EditProps;
51
52export default function DepartmentRoleForm(props: Props) {
53 return props.mode === "create" ? (
54 <CreateForm {...props} />
55 ) : (
56 <EditForm {...props} />
57 );
58}
59
60function CreateForm({ onSubmit, onCancel }: CreateProps) {
61 const form = useForm<
62 DepartmentRoleCreateInput,
63 undefined,
64 DepartmentRoleCreateValues
65 >({
66 resolver: zodResolver(departmentRoleCreateSchema),
67 defaultValues: {
68 code: "",
69 name: "",
70 priority: 0,
71 badgeColor: "#666666",
72 isEnabled: true,
73 canDownloadData: false,
74 canEditData: false,
75 remarks: "",
76 },
77 mode: "onBlur",
78 });
79
80 const handleSubmit = form.handleSubmit(onSubmit);
81
82 return (
83 <Form {...form}>
84 <form data-testid="department-role-form-create" onSubmit={handleSubmit}>
85 <Card className="w-full rounded-md">
86 <CardContent className="space-y-6 pt-1">
87 <CodeField />
88 <NameField />
89 <PriorityField />
90 <BadgeColorField />
91 <IsEnabledField />
92 <CanDownloadField />
93 <CanEditField />
94 <RemarksField />
95 </CardContent>
96 <CardFooter className="mt-4 flex gap-2">
97 <Button
98 type="button"
99 variant="outline"
100 onClick={onCancel}
101 data-testid="cancel-btn"
102 className="cursor-pointer"
103 >
104 キャンセル
105 </Button>
106 <Button
107 type="submit"
108 data-testid="submit-create"
109 disabled={form.formState.isSubmitting}
110 className="cursor-pointer"
111 >
112 登録する
113 </Button>
114 </CardFooter>
115 </Card>
116 </form>
117 </Form>
118 );
119}
120
121function EditForm({
122 initialValues,
123 onSubmit,
124 onCancel,
125 onDelete,
126 readOnly = false,
127}: EditProps) {
128 const form = useForm<
129 DepartmentRoleUpdateInput,
130 undefined,
131 DepartmentRoleUpdateValues
132 >({
133 resolver: zodResolver(departmentRoleUpdateSchema),
134 defaultValues: initialValues,
135 mode: "onBlur",
136 });
137
138 const handleSubmit = form.handleSubmit(onSubmit);
139 const isCustom = initialValues.kind === "custom";
140
141 return (
142 <Form {...form}>
143 <form data-testid="department-role-form-edit" onSubmit={handleSubmit}>
144 <Card className="w-full rounded-md">
145 <CardContent className="space-y-6 pt-1">
146 {isCustom ? (
147 <>
148 <DisplayIdField />
149 <CodeField readOnly={readOnly} />
150 <NameField readOnly={readOnly} />
151 <PriorityField readOnly={readOnly} />
152 <BadgeColorField readOnly={readOnly} />
153 <IsEnabledField readOnly={readOnly} />
154 <CanDownloadField readOnly={readOnly} />
155 <CanEditField readOnly={readOnly} />
156 <RemarksField readOnly={readOnly} />
157 </>
158 ) : (
159 <>
160 {/* override: 実効値をそのまま初期表示(テキスト+カラー) */}
161 <OverrideNameField readOnly={readOnly} />
162 <OverrideBadgeColorField readOnly={readOnly} />
163 <IsEnabledField readOnly={readOnly} />
164 </>
165 )}
166 </CardContent>
167
168 <CardFooter className="mt-4 flex items-center justify-between">
169 <div className="flex gap-2">
170 <Button
171 type="button"
172 variant="outline"
173 onClick={onCancel}
174 data-testid="cancel-btn"
175 className="cursor-pointer"
176 >
177 キャンセル
178 </Button>
179 {!readOnly && (
180 <Button
181 type="submit"
182 data-testid="submit-update"
183 disabled={form.formState.isSubmitting}
184 className="cursor-pointer"
185 >
186 更新する
187 </Button>
188 )}
189 </div>
190 {!readOnly && onDelete && (
191 <Button
192 type="button"
193 variant="destructive"
194 onClick={onDelete}
195 data-testid="delete"
196 className="cursor-pointer"
197 >
198 削除する
199 </Button>
200 )}
201 </CardFooter>
202 </Card>
203 </form>
204 </Form>
205 );
206}
207
208/* ===== 共通フィールド ===== */
209
210function DisplayIdField() {
211 return (
212 <FormField
213 name="displayId"
214 render={({ field }) => (
215 <FormItem>
216 <FormLabel>表示ID</FormLabel>
217 <FormControl>
218 <Input
219 {...field}
220 readOnly
221 aria-readonly="true"
222 data-testid="displayId"
223 className="text-muted-foreground bg-muted border-none focus-visible:ring-0"
224 />
225 </FormControl>
226 <FormDescription>DBで自動採番される表示IDです。</FormDescription>
227 <FormMessage />
228 </FormItem>
229 )}
230 />
231 );
232}
233
234function CodeField({ readOnly = false }: { readOnly?: boolean }) {
235 return (
236 <FormField
237 name="code"
238 render={({ field }) => (
239 <FormItem>
240 <FormLabel className="font-semibold">コード *</FormLabel>
241 <FormControl>
242 <Input
243 {...field}
244 placeholder="ANALYST"
245 aria-label="コード"
246 autoComplete="off"
247 data-testid="code"
248 readOnly={readOnly}
249 aria-readonly={readOnly}
250 className={readOnly ? "bg-muted pointer-events-none" : ""}
251 />
252 </FormControl>
253 <FormMessage data-testid="code-error" />
254 </FormItem>
255 )}
256 />
257 );
258}
259
260function NameField({ readOnly = false }: { readOnly?: boolean }) {
261 return (
262 <FormField
263 name="name"
264 render={({ field }) => (
265 <FormItem>
266 <FormLabel className="font-semibold">表示名 *</FormLabel>
267 <FormControl>
268 <Input
269 {...field}
270 placeholder="分析担当"
271 aria-label="表示名"
272 autoComplete="off"
273 data-testid="name"
274 readOnly={readOnly}
275 aria-readonly={readOnly}
276 className={readOnly ? "bg-muted pointer-events-none" : ""}
277 />
278 </FormControl>
279 <FormMessage data-testid="name-error" />
280 </FormItem>
281 )}
282 />
283 );
284}
285
286function PriorityField({ readOnly = false }: { readOnly?: boolean }) {
287 return (
288 <FormField
289 name="priority"
290 render={({ field }) => (
291 <FormItem>
292 <FormLabel className="font-semibold">優先度 *</FormLabel>
293 <FormControl>
294 <Input
295 type="number"
296 inputMode="numeric"
297 min={0}
298 max={DR_PRIORITY_MAX}
299 step={1}
300 {...field}
301 value={field.value == null ? "" : String(field.value)}
302 onChange={(e) =>
303 field.onChange(e.target.value === "" ? "" : e.target.value)
304 }
305 placeholder="20"
306 aria-label="優先度"
307 data-testid="priority"
308 readOnly={readOnly}
309 aria-readonly={readOnly}
310 className={readOnly ? "bg-muted pointer-events-none" : ""}
311 />
312 </FormControl>
313 <FormDescription className="text-xs">
314 0〜{DR_PRIORITY_MAX} の整数で入力してください。
315 </FormDescription>
316 <FormMessage data-testid="priority-error" />
317 </FormItem>
318 )}
319 />
320 );
321}
322
323function BadgeColorField({ readOnly = false }: { readOnly?: boolean }) {
324 return (
325 <FormField
326 name="badgeColor"
327 render={({ field }) => (
328 <FormItem>
329 <FormLabel className="font-semibold">バッジ色</FormLabel>
330 <FormControl>
331 <Input
332 type="color"
333 {...field}
334 aria-label="バッジ色"
335 data-testid="badgeColor"
336 disabled={readOnly}
337 className={readOnly ? "opacity-70" : "cursor-pointer"}
338 />
339 </FormControl>
340 <FormMessage data-testid="badgeColor-error" />
341 </FormItem>
342 )}
343 />
344 );
345}
346
347function IsEnabledField({ readOnly = false }: { readOnly?: boolean }) {
348 return (
349 <FormField
350 name="isEnabled"
351 render={({ field }) => (
352 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
353 <div className="space-y-0.5">
354 <FormLabel className="font-semibold">有効 *</FormLabel>
355 <FormDescription>ONで有効/OFFで無効</FormDescription>
356 </div>
357 <FormControl>
358 <Switch
359 name={field.name}
360 checked={Boolean(field.value)}
361 onCheckedChange={field.onChange}
362 aria-label="有効"
363 data-testid="isEnabled"
364 disabled={readOnly}
365 />
366 </FormControl>
367 <FormMessage data-testid="isEnabled-error" />
368 </FormItem>
369 )}
370 />
371 );
372}
373
374function CanDownloadField({ readOnly = false }: { readOnly?: boolean }) {
375 return (
376 <FormField
377 name="canDownloadData"
378 render={({ field }) => (
379 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
380 <FormLabel className="font-semibold">データDL可 *</FormLabel>
381 <FormControl>
382 <Switch
383 name={field.name}
384 checked={Boolean(field.value)}
385 onCheckedChange={field.onChange}
386 aria-label="データDL可"
387 data-testid="canDownloadData"
388 disabled={readOnly}
389 />
390 </FormControl>
391 <FormMessage data-testid="canDownloadData-error" />
392 </FormItem>
393 )}
394 />
395 );
396}
397
398function CanEditField({ readOnly = false }: { readOnly?: boolean }) {
399 return (
400 <FormField
401 name="canEditData"
402 render={({ field }) => (
403 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
404 <FormLabel className="font-semibold">データ編集可 *</FormLabel>
405 <FormControl>
406 <Switch
407 name={field.name}
408 checked={Boolean(field.value)}
409 onCheckedChange={field.onChange}
410 aria-label="データ編集可"
411 data-testid="canEditData"
412 disabled={readOnly}
413 />
414 </FormControl>
415 <FormMessage data-testid="canEditData-error" />
416 </FormItem>
417 )}
418 />
419 );
420}
421
422function RemarksField({ readOnly = false }: { readOnly?: boolean }) {
423 return (
424 <FormField
425 name="remarks"
426 render={({ field }) => (
427 <FormItem>
428 <FormLabel className="font-semibold">備考</FormLabel>
429 <FormControl>
430 <Input
431 {...field}
432 placeholder="メモなど"
433 aria-label="備考"
434 autoComplete="off"
435 data-testid="remarks"
436 readOnly={readOnly}
437 aria-readonly={readOnly}
438 className={readOnly ? "bg-muted pointer-events-none" : ""}
439 />
440 </FormControl>
441 <FormMessage data-testid="remarks-error" />
442 </FormItem>
443 )}
444 />
445 );
446}
447
448/* ===== override 用(そのまま初期表示) ===== */
449
450function OverrideNameField({ readOnly = false }: { readOnly?: boolean }) {
451 return (
452 <FormField
453 name="nameOverride"
454 render={({ field }) => (
455 <FormItem>
456 <FormLabel className="font-semibold">表示名(上書き)</FormLabel>
457 <FormControl>
458 <Input
459 {...field}
460 placeholder="表示名(ベースを上書き)"
461 aria-label="表示名(上書き)"
462 autoComplete="off"
463 data-testid="nameOverride"
464 readOnly={readOnly}
465 aria-readonly={readOnly}
466 className={readOnly ? "bg-muted pointer-events-none" : ""}
467 />
468 </FormControl>
469 <FormMessage data-testid="nameOverride-error" />
470 </FormItem>
471 )}
472 />
473 );
474}
475
476function OverrideBadgeColorField({ readOnly = false }: { readOnly?: boolean }) {
477 return (
478 <FormField
479 name="badgeColorOverride"
480 render={({ field }) => (
481 <FormItem>
482 <FormLabel className="font-semibold">バッジ色(上書き)</FormLabel>
483 <FormControl>
484 <Input
485 type="color"
486 {...field}
487 aria-label="バッジ色(上書き)"
488 data-testid="badgeColorOverride"
489 disabled={readOnly}
490 className={readOnly ? "opacity-70" : "cursor-pointer"}
491 />
492 </FormControl>
493 <FormMessage data-testid="badgeColorOverride-error" />
494 </FormItem>
495 )}
496 />
497 );
498}
EditForm
はinitialValues.kind
で表示を切り替えます。- override 初回(DR 無)でも UI は同じ。
roleDisplayId
は ページ側でinitialValues
に含めて 渡します。
画面:/masters/roles/[displayId](SSR ガード+初期値解決)
displayId
が DR か RL かで分岐し、 編集用初期値 を合成して Client に渡します。- Role 単体(override 無) の場合でも、
kind: "override"
のinitialValues
を構成します(isEnabled: true
、nameOverride/badgeColorOverride: undefined
)。
tsx
1// src/app/(protected)/masters/roles/[displayId]/page.tsx
2
3import type { Metadata } from "next";
4import { notFound } from "next/navigation";
5
6import {
7 Breadcrumb,
8 BreadcrumbItem,
9 BreadcrumbLink,
10 BreadcrumbList,
11 BreadcrumbPage,
12 BreadcrumbSeparator,
13} from "@/components/ui/breadcrumb";
14import { Separator } from "@/components/ui/separator";
15import { SidebarTrigger } from "@/components/ui/sidebar";
16
17import { prisma } from "@/lib/database";
18import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
19import Client from "./client";
20
21import type { DepartmentRoleUpdateValues } from "@/lib/department-roles/schema";
22
23export const metadata: Metadata = {
24 title: "ロール編集",
25 description: "部署ローカル(custom)/ベースロール上書き(override)の編集",
26};
27
28export default async function Page({
29 params,
30}: {
31 params: Promise<{ displayId: string }>;
32}) {
33 const { displayId } = await params;
34
35 // SSR ガード(userId を取得)
36 const viewer = await guardHrefOrRedirect(`/masters/roles/${displayId}`, "/");
37
38 // 部署ID
39 const me = await prisma.user.findUnique({
40 where: { id: viewer.userId },
41 select: { departmentId: true },
42 });
43 if (!me?.departmentId) notFound();
44
45 let initialValues: DepartmentRoleUpdateValues | null = null;
46
47 if (displayId.startsWith("DR")) {
48 // DR を直接取得(custom / override どちらでも)
49 const dr = await prisma.departmentRole.findUnique({
50 where: { displayId },
51 select: {
52 displayId: true,
53 departmentId: true,
54 roleId: true,
55 // custom
56 code: true,
57 name: true,
58 priority: true,
59 badgeColor: true,
60 canDownloadData: true,
61 canEditData: true,
62 isEnabled: true,
63 remarks: true,
64 // override
65 nameOverride: true,
66 badgeColorOverride: true,
67 role: { select: { displayId: true, name: true, badgeColor: true } },
68 },
69 });
70 if (!dr || dr.departmentId !== me.departmentId) notFound();
71
72 if (dr.roleId) {
73 // override(既存)。UI には「実効値」を初期表示させる
74 const effName = dr.nameOverride ?? dr.role?.name ?? "";
75 const effColor =
76 dr.badgeColorOverride ?? dr.role?.badgeColor ?? "#000000";
77
78 initialValues = {
79 kind: "override",
80 displayId: dr.displayId,
81 roleDisplayId: dr.role?.displayId, // 既存でも保持しておくと便利
82 isEnabled: dr.isEnabled,
83 nameOverride: effName, // ← 実効値をそのまま初期表示
84 badgeColorOverride: effColor, // ← 実効値をそのまま初期表示
85 };
86 } else {
87 // custom
88 initialValues = {
89 kind: "custom",
90 displayId: dr.displayId,
91 code: dr.code ?? "",
92 name: dr.name ?? "",
93 priority: dr.priority ?? 0,
94 badgeColor: dr.badgeColor ?? "#000000",
95 isEnabled: dr.isEnabled,
96 canDownloadData: dr.canDownloadData ?? false,
97 canEditData: dr.canEditData ?? false,
98 remarks: dr.remarks ?? undefined,
99 };
100 }
101 } else if (displayId.startsWith("RL")) {
102 // Role から編集(override 初回 or 既存)
103 const role = await prisma.role.findUnique({
104 where: { displayId },
105 select: {
106 displayId: true,
107 name: true,
108 badgeColor: true,
109 departmentRoles: {
110 where: { departmentId: me.departmentId, roleId: { not: null } },
111 take: 1,
112 select: {
113 displayId: true,
114 isEnabled: true,
115 nameOverride: true,
116 badgeColorOverride: true,
117 },
118 },
119 },
120 });
121 if (!role) notFound();
122
123 const ov = role.departmentRoles[0];
124 if (ov) {
125 // 既存 override:実効値で初期表示(override 優先、なければ Role)
126 const effName = ov.nameOverride ?? role.name;
127 const effColor = ov.badgeColorOverride ?? role.badgeColor ?? "#000000";
128
129 initialValues = {
130 kind: "override",
131 displayId: ov.displayId,
132 roleDisplayId: role.displayId,
133 isEnabled: ov.isEnabled,
134 nameOverride: effName,
135 badgeColorOverride: effColor,
136 };
137 } else {
138 // 初回 override:Role 値をそのまま初期値に入れて表示
139 initialValues = {
140 kind: "override",
141 roleDisplayId: role.displayId,
142 isEnabled: true,
143 nameOverride: role.name, // ← そのまま表示
144 badgeColorOverride: role.badgeColor ?? "#000000", // ← そのまま表示
145 };
146 }
147 } else {
148 notFound();
149 }
150
151 if (!initialValues) notFound();
152
153 return (
154 <>
155 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
156 <div className="flex items-center gap-2 px-4">
157 <SidebarTrigger className="-ml-1" />
158 <Separator
159 orientation="vertical"
160 className="mr-2 data-[orientation=vertical]:h-4"
161 />
162 <Breadcrumb>
163 <BreadcrumbList>
164 <BreadcrumbItem className="hidden md:block">
165 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
166 </BreadcrumbItem>
167 <BreadcrumbSeparator className="hidden md:block" />
168 <BreadcrumbItem className="hidden md:block">
169 <BreadcrumbLink href="/masters/roles">
170 ロール管理
171 </BreadcrumbLink>
172 </BreadcrumbItem>
173 <BreadcrumbSeparator className="hidden md:block" />
174 <BreadcrumbItem>
175 <BreadcrumbPage>ロール編集({displayId})</BreadcrumbPage>
176 </BreadcrumbItem>
177 </BreadcrumbList>
178 </Breadcrumb>
179 </div>
180 </header>
181
182 <div className="max-w-xl p-4 pt-0">
183 <Client
184 initialValues={initialValues}
185 canEditData={viewer.canEditData}
186 />
187 </div>
188 </>
189 );
190}
- DR 起点 :
roleId
の有無で custom / override を判定。 - RL 起点 :同部署の override 有無で分岐。無ければ 初回 override 作成 の初期値を組み立てます。
tsx
1// src/app/(protected)/masters/roles/[displayId]/client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { toast } from "sonner";
6
7import DepartmentRoleForm from "@/components/department-roles/department-role-form";
8import type { DepartmentRoleUpdateValues } from "@/lib/department-roles/schema";
9import {
10 updateDepartmentRole,
11 deleteDepartmentRole,
12} from "@/app/_actions/department-roles/update";
13
14type Props = {
15 initialValues: DepartmentRoleUpdateValues;
16 canEditData?: boolean;
17};
18
19export default function EditDepartmentRoleClient({
20 initialValues,
21 canEditData = false,
22}: Props) {
23 const router = useRouter();
24
25 const handleSubmit = async (values: DepartmentRoleUpdateValues) => {
26 if (!canEditData) return; // 念のための保険(編集権がないと通常はボタン非表示)
27 // override の空入力は「ベース値を使う」= undefined に正規化
28 const normalized: DepartmentRoleUpdateValues =
29 values.kind === "override"
30 ? {
31 ...values,
32 nameOverride:
33 values.nameOverride && values.nameOverride.trim() !== ""
34 ? values.nameOverride
35 : undefined,
36 badgeColorOverride:
37 values.badgeColorOverride &&
38 values.badgeColorOverride.trim() !== ""
39 ? values.badgeColorOverride
40 : undefined,
41 }
42 : values;
43
44 const res = await updateDepartmentRole(normalized);
45
46 if (res.ok) {
47 toast.success("ロールを更新しました", { duration: 3000 });
48 router.push("/masters/roles");
49 router.refresh();
50 return;
51 }
52 toast.error(res.message ?? "更新に失敗しました。", { duration: 3500 });
53 };
54
55 const handleDelete =
56 canEditData && (initialValues.kind === "custom" || initialValues.displayId)
57 ? async () => {
58 const id =
59 initialValues.kind === "custom"
60 ? initialValues.displayId
61 : (initialValues.displayId as string);
62 const res = await deleteDepartmentRole(id);
63 if (res.ok) {
64 toast.success(
65 initialValues.kind === "custom"
66 ? "ロールを削除しました"
67 : "上書きを削除しました(ベースに戻ります)",
68 );
69 router.push("/masters/roles");
70 router.refresh();
71 } else {
72 toast.error(res.message ?? "削除に失敗しました。");
73 }
74 }
75 : undefined;
76
77 return (
78 <DepartmentRoleForm
79 mode="edit"
80 initialValues={initialValues}
81 onSubmit={canEditData ? handleSubmit : () => {}}
82 onCancel={() => history.back()}
83 onDelete={canEditData ? handleDelete : undefined}
84 readOnly={!canEditData}
85 />
86 );
87}
- 更新成功 :トースト →
/masters/roles
へ戻る →router.refresh()
。 - 削除 :custom はレコード削除、override は DR を削除して Role 素に戻る 。
- 初回 override は削除ボタンを出さない(対象が未作成だから)。
動作確認の観点(チェックリスト)
観点 | 期待動作 |
---|---|
DR(custom)編集 | code 重複は部署内で拒否。priority ≤ 99 。有効/無効・権限フラグが保存される。 |
RL(override無) | 画面で上書き値を入力 → 保存で DR(override)新規作成。一覧で反映される。 |
RL/DR(override有) | 上書き値の変更・isEnabled 切り替えが保存される。削除で Role 素に戻る。 |
権限 | canEditData = false のユーザは SA で弾かれる(UI からも遷移不可が望ましい)。 |
直リンク | guardHrefOrRedirect が未ログイン・権限不足を遮断する。 |
- 編集画面は 1 画面で custom / override を統合 し、
kind
で分岐。 - override 初回作成 (RL 起点)に対応しておくと、テナント運用の導線がシンプルになります。
- サーバ側は 認証/権限/部署/入力 の 4 点を毎回チェックする共通骨格にすると、以降の CRUD も迷いません。
3. 一覧
部署にとっての「ロール」は
ここではユーザ一覧の実装をテンプレートに、
Role
と DepartmentRole
が 同一テーブルに混在 して見えることが重要です。ここではユーザ一覧の実装をテンプレートに、
/masters/roles
で ベースロール / 上書き / 部署カスタム をひとつの DataTable にまとめて表示・検索・ソート・CSV 出力できるようにします。txt
1[一覧に並ぶ行のイメージ]
2
3┌──────────┬──────────┬────────┬──────────────┬─────────┬───────────┬──────────┬─────────────┐
4│ 表示ID │ コード │ 種別 │ 表示名(実効) │ 優先度 │ 編集可 │ DL可 │ 状態(部署) │ ...
5├──────────┼──────────┼────────┼──────────────┼─────────┼───────────┼──────────┼─────────────┤
6│ RL... │ ADMIN │ role │ 管理者 │ 100 │ ✅ │ ✅ │ 有効(=true) │ ← overrideなし
7│ RL... │ VIEWER │ override│ ビュー専用 │ 10 │ ❌ │ ❌ │ 無効 │ ← 部署のoverride適用
8│ DR... │ ANALYST │ custom │ 分析担当 │ 20 │ ✅ │ ❌ │ 有効 │ ← 部署ローカル作成
9└──────────┴──────────┴────────┴──────────────┴─────────┴───────────┴──────────┴─────────────┘
10※ 編集リンクは kind=role/override → `/masters/roles/RL...`
11 kind=custom → `/masters/roles/DR...`
表示仕様(ベース+上書き+カスタムを統合)
列 | 値の出所(role 行) | 値の出所(custom 行) |
---|---|---|
表示ID | Role.displayId | DepartmentRole.displayId |
コード | Role.code | DepartmentRole.code |
種別(kind) | override が存在すれば override 、なければ role | 常に custom |
表示名(実効) | nameOverride ?? Role.name | DepartmentRole.name |
バッジ色(実効) | badgeColorOverride ?? Role.badgeColor | DepartmentRole.badgeColor |
優先度 | Role.priority | DepartmentRole.priority |
編集可/ダウンロード可 | Role.canEditData / Role.canDownloadData | DepartmentRole.canEditData / canDownloadData |
状態(部署での有効) | override ? override.isEnabled : true | DepartmentRole.isEnabled |
備考 | - | DepartmentRole.remarks |
編集リンク | /masters/roles/{Role.displayId} | /masters/roles/{DepartmentRole.displayId} |
以降は SSR でデータを合成 → クライアントの DataTable に渡す という流れで実装します。
ロール種別(kind) フィルタの追加
ロール一覧では、ロール種別(kind) フィルタを利用します。まずは、フィルタとなる複数選択可能なリストファイルを作成します。
ts
1// src/app/(protected)/masters/roles/types-checklist.tsx
2"use client";
3
4import * as React from "react";
5import { Check } from "lucide-react";
6import { Button } from "@/components/ui/button";
7import {
8 Command,
9 CommandGroup,
10 CommandItem,
11 CommandInput,
12 CommandEmpty,
13} from "@/components/ui/command";
14import { Separator } from "@/components/ui/separator";
15
16export function TypesChecklist({
17 value,
18 onChange,
19 options, // [{value:'role',label:'ベース'}, ...]
20 footer,
21}: {
22 value: string[];
23 onChange: (next: string[]) => void;
24 options: { value: string; label: string }[];
25 footer?: React.ReactNode;
26}) {
27 const [needle, setNeedle] = React.useState("");
28
29 const toggle = (v: string) => {
30 const set = new Set(value);
31 if (set.has(v)) {
32 set.delete(v);
33 } else {
34 set.add(v);
35 }
36 onChange(Array.from(set));
37 };
38
39 const all = options.map((o) => o.value);
40 const allSelected = value.length === all.length;
41 const noneSelected = value.length === 0;
42
43 const filtered = React.useMemo(() => {
44 const q = needle.trim().toLowerCase();
45 if (!q) return options;
46 return options.filter(
47 (o) =>
48 o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
49 );
50 }, [needle, options]);
51
52 return (
53 <div className="flex max-h-[60vh] w-full flex-col">
54 <div className="p-2">
55 <Command shouldFilter={false}>
56 <CommandInput
57 value={needle}
58 onValueChange={setNeedle}
59 placeholder="種別を検索…"
60 />
61 <CommandEmpty>該当する種別がありません</CommandEmpty>
62 <CommandGroup heading="種別(複数選択可)">
63 {filtered.map((o) => {
64 const checked = value.includes(o.value);
65 return (
66 <CommandItem
67 key={o.value}
68 onSelect={() => toggle(o.value)}
69 className="flex items-center gap-2"
70 >
71 <span
72 className="flex h-5 w-5 items-center justify-center rounded border"
73 aria-checked={checked}
74 role="checkbox"
75 >
76 {checked ? <Check className="h-3 w-3" /> : null}
77 </span>
78 <span className="truncate">{o.label}</span>
79 </CommandItem>
80 );
81 })}
82 </CommandGroup>
83 </Command>
84 </div>
85
86 <Separator />
87
88 <div className="flex items-center justify-between gap-2 p-2">
89 <div className="flex gap-2">
90 <Button
91 type="button"
92 variant="ghost"
93 size="sm"
94 onClick={() => onChange(all)}
95 disabled={allSelected}
96 className="cursor-pointer"
97 >
98 すべて
99 </Button>
100 <Button
101 type="button"
102 variant="ghost"
103 size="sm"
104 onClick={() => onChange([])}
105 disabled={noneSelected}
106 className="cursor-pointer"
107 >
108 クリア
109 </Button>
110 </div>
111 <div className="flex items-center">{footer}</div>
112 </div>
113 </div>
114 );
115}
これは新規のフィルタとなるので、
src/types/table-meta.d.ts
にも追記します。ts
1// src/types/table-meta.d.ts
2import "@tanstack/table-core";
3
4declare module "@tanstack/table-core" {
5 interface TableMeta<TData extends RowData> {
6 onMoveUp?: (id: string, _row?: TData) => void;
7 onMoveDown?: (id: string, _row?: TData) => void;
8 /** 依頼を「再発行済み」にする */
9 onIssue?: (id: string, _row?: TData) => void | Promise<void>;
10 /** 依頼を「拒否」にする */
11 onReject?: (id: string, _row?: TData) => void | Promise<void>;
12 /** ★ 追加:メール変更申請を「承認」する */
13 onApprove?: (id: string, _row?: TData) => void | Promise<void>;
14 // ▼ ユーザ一覧用(必要なものだけ)
15 roleOptions?: Array<{ value: string; label: string }>;
16 roles?: string[];
17 setRoles?: (next: string[]) => void;
18
19 status?: "ALL" | "ACTIVE" | "INACTIVE";
20 setStatus?: (next: "ALL" | "ACTIVE" | "INACTIVE") => void;
21
22 createdRange?: import("react-day-picker").DateRange | undefined;
23 setCreatedRange?: (
24 r: import("react-day-picker").DateRange | undefined,
25 ) => void;
26
27 updatedRange?: import("react-day-picker").DateRange | undefined;
28 setUpdatedRange?: (
29 r: import("react-day-picker").DateRange | undefined,
30 ) => void;
31
32 // ▼ ロール一覧用(masters/roles)
33 kindOptions?: Array<{ value: string; label: string }>;
34 kinds?: string[];
35 setKinds?: (next: string[]) => void;
36 }
37}
38
39export {};
これで一覧で利用可能なフィルタが増えます。
カラムファイルの作成
tsx
1// src/app/(protected)/masters/roles/columns.tsx
2"use client";
3
4import Link from "next/link";
5import * as React from "react";
6import type { ColumnDef, HeaderContext } from "@tanstack/react-table";
7import { Badge } from "@/components/ui/badge";
8import { Button } from "@/components/ui/button";
9import {
10 Tooltip,
11 TooltipContent,
12 TooltipTrigger,
13} from "@/components/ui/tooltip";
14import { SquarePen, SlidersVertical } from "lucide-react";
15import { format } from "date-fns";
16import { ja } from "date-fns/locale";
17import {
18 Popover,
19 PopoverContent,
20 PopoverTrigger,
21} from "@/components/ui/popover";
22import * as PopoverPrimitive from "@radix-ui/react-popover";
23import { StatusFilter } from "@/components/filters/status-filter";
24import { SortButton } from "@/components/datagrid/sort-button";
25import { TypesChecklist } from "./types-checklist";
26// ★ 追加:日付レンジフィルタ
27import { DateRangePicker } from "@/components/filters/date-range-picker";
28
29export type DepartmentRoleRow = {
30 displayId: string; // RL... or DR...
31 code: string; // 表示用コード
32 kind: "role" | "override" | "custom";
33 nameEffective: string;
34 badgeColorEffective: string | null;
35 priority: number;
36 canEditData: boolean;
37 canDownloadData: boolean;
38 isEnabledInDepartment: boolean; // 部署視点の有効
39 remarks: string | null; // customのみ
40 createdAt: Date;
41 updatedAt: Date;
42};
43
44function fmt(dt: Date) {
45 return format(dt, "yyyy/MM/dd HH:mm", { locale: ja });
46}
47
48function HeaderWithFilter({
49 title,
50 active,
51 children,
52 contentClassName,
53 trailing,
54}: {
55 title: string;
56 active: boolean;
57 children: React.ReactNode;
58 contentClassName?: string;
59 trailing?: React.ReactNode;
60}) {
61 return (
62 <div className="flex items-center gap-1">
63 <span className="whitespace-nowrap">{title}</span>
64 <Popover>
65 <PopoverTrigger asChild>
66 <Button
67 type="button"
68 size="icon"
69 variant={active ? "default" : "outline"}
70 className="h-7 w-7 cursor-pointer"
71 aria-label={`${title}のフィルタ`}
72 title={`${title}のフィルタ`}
73 >
74 <SlidersVertical className="h-3.5 w-3.5" />
75 </Button>
76 </PopoverTrigger>
77 <PopoverContent
78 align="end"
79 className={["p-0", contentClassName ?? "w-80"].join(" ")}
80 >
81 {children}
82 </PopoverContent>
83 </Popover>
84 {trailing ? <div className="ml-0.5">{trailing}</div> : null}
85 </div>
86 );
87}
88
89function HeaderWithSort<TData, TValue>({
90 title,
91 ctx,
92}: {
93 title: string;
94 ctx: HeaderContext<TData, TValue>;
95}) {
96 return (
97 <div className="flex items-center gap-1">
98 <span className="whitespace-nowrap">{title}</span>
99 <SortButton
100 column={ctx.column}
101 aria-label={`${title}でソート`}
102 title={`${title}でソート`}
103 />
104 </div>
105 );
106}
107
108export const columns: ColumnDef<DepartmentRoleRow>[] = [
109 {
110 id: "actions",
111 header: "操作",
112 enableResizing: false,
113 size: 40,
114 enableSorting: false,
115 cell: ({ row }) => (
116 <Tooltip>
117 <TooltipTrigger asChild>
118 <Button
119 asChild
120 size="icon"
121 variant="outline"
122 data-testid={`edit-${row.original.displayId}`}
123 className="size-8 cursor-pointer"
124 >
125 <Link href={`/masters/roles/${row.original.displayId}`}>
126 <SquarePen />
127 </Link>
128 </Button>
129 </TooltipTrigger>
130 <TooltipContent>
131 <p>参照・編集</p>
132 </TooltipContent>
133 </Tooltip>
134 ),
135 },
136
137 {
138 accessorKey: "displayId",
139 header: (ctx) => <HeaderWithSort title="表示ID" ctx={ctx} />,
140 cell: ({ row }) => (
141 <span className="font-mono">{row.original.displayId}</span>
142 ),
143 },
144 {
145 accessorKey: "code",
146 header: (ctx) => <HeaderWithSort title="コード" ctx={ctx} />,
147 },
148
149 // 種別フィルタ(role/override/custom)
150 {
151 accessorKey: "kind",
152 header: (ctx) => {
153 const table = ctx.table;
154 const kindOptions = table.options.meta?.kindOptions ?? [];
155 const kinds =
156 table.options.meta?.kinds ??
157 kindOptions.map((o: { value: string }) => o.value);
158 const setKinds = table.options.meta?.setKinds ?? (() => {});
159 const active = kinds.length !== kindOptions.length;
160
161 return (
162 <HeaderWithFilter
163 title="種別"
164 active={active}
165 contentClassName="w-[300px]"
166 trailing={
167 <SortButton
168 column={ctx.column}
169 aria-label="種別でソート"
170 title="種別でソート"
171 />
172 }
173 >
174 <TypesChecklist
175 value={kinds}
176 onChange={setKinds}
177 options={kindOptions}
178 footer={
179 <PopoverPrimitive.Close asChild>
180 <Button
181 variant="ghost"
182 size="sm"
183 type="button"
184 className="cursor-pointer"
185 >
186 閉じる
187 </Button>
188 </PopoverPrimitive.Close>
189 }
190 />
191 </HeaderWithFilter>
192 );
193 },
194 size: 64,
195 enableResizing: false,
196 cell: ({ row }) => {
197 const k = row.original.kind;
198 const label =
199 k === "role" ? "ベース" : k === "override" ? "上書き" : "部署ローカル";
200 return (
201 <Badge variant={k === "custom" ? "secondary" : "outline"}>
202 {label}
203 </Badge>
204 );
205 },
206 },
207
208 // 表示名(実効)+色バッジ
209 {
210 accessorKey: "nameEffective",
211 header: (ctx) => <HeaderWithSort title="表示名" ctx={ctx} />,
212 cell: ({ row }) => {
213 const color = row.original.badgeColorEffective;
214 const style = color
215 ? { backgroundColor: color, color: "#fff", border: "none" }
216 : undefined;
217 return (
218 <div className="flex items-center gap-2">
219 <Badge
220 variant={color ? "secondary" : "outline"}
221 style={style}
222 title={color ?? ""}
223 >
224 {row.original.nameEffective}
225 </Badge>
226 </div>
227 );
228 },
229 },
230
231 {
232 accessorKey: "priority",
233 header: (ctx) => <HeaderWithSort title="優先度" ctx={ctx} />,
234 size: 60,
235 },
236
237 {
238 accessorKey: "canEditData",
239 header: (ctx) => <HeaderWithSort title="編集可" ctx={ctx} />,
240 size: 56,
241 cell: ({ row }) => (row.original.canEditData ? "✅" : "—"),
242 },
243 {
244 accessorKey: "canDownloadData",
245 header: (ctx) => <HeaderWithSort title="DL可" ctx={ctx} />,
246 size: 56,
247 cell: ({ row }) => (row.original.canDownloadData ? "✅" : "—"),
248 },
249
250 // 状態(部署での有効)フィルタ
251 {
252 accessorKey: "isEnabledInDepartment",
253 header: (ctx) => {
254 const table = ctx.table;
255 const status = table.options.meta?.status ?? "ALL";
256 const setStatus: (next: "ALL" | "ACTIVE" | "INACTIVE") => void =
257 table.options.meta?.setStatus ?? (() => {});
258 const active = status !== "ALL";
259 return (
260 <HeaderWithFilter
261 title="状態"
262 active={active}
263 contentClassName="w-[220px]"
264 trailing={
265 <SortButton
266 column={ctx.column}
267 aria-label="状態でソート"
268 title="状態でソート"
269 />
270 }
271 >
272 <StatusFilter value={status} onChange={setStatus} />
273 </HeaderWithFilter>
274 );
275 },
276 size: 50,
277 enableResizing: false,
278 cell: ({ row }) =>
279 row.original.isEnabledInDepartment ? (
280 <Badge data-testid="badge-enabled">有効</Badge>
281 ) : (
282 <Badge variant="outline" data-testid="badge-disabled">
283 無効
284 </Badge>
285 ),
286 },
287
288 {
289 accessorKey: "remarks",
290 header: (ctx) => <HeaderWithSort title="備考" ctx={ctx} />,
291 size: 150,
292 cell: ({ row }) => (
293 <div className="max-w-[150px] truncate">{row.original.remarks}</div>
294 ),
295 },
296
297 // ★ 登録日時(フィルタ+ソート)
298 {
299 accessorKey: "createdAt",
300 header: (ctx) => {
301 const table = ctx.table;
302 const r = table.options.meta?.createdRange;
303 const setR: (
304 r: import("react-day-picker").DateRange | undefined,
305 ) => void = table.options.meta?.setCreatedRange ?? (() => {});
306 const active = !!(r?.from || r?.to);
307 return (
308 <HeaderWithFilter
309 title="登録日時"
310 active={active}
311 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]"
312 trailing={
313 <SortButton
314 column={ctx.column}
315 aria-label="登録日時でソート"
316 title="登録日時でソート"
317 />
318 }
319 >
320 <DateRangePicker label="登録日時" value={r} onChange={setR} />
321 </HeaderWithFilter>
322 );
323 },
324 size: 120,
325 cell: ({ row }) => fmt(row.original.createdAt),
326 },
327
328 // ★ 更新日時(フィルタ+ソート)
329 {
330 accessorKey: "updatedAt",
331 header: (ctx) => {
332 const table = ctx.table;
333 const r = table.options.meta?.updatedRange;
334 const setR: (
335 r: import("react-day-picker").DateRange | undefined,
336 ) => void = table.options.meta?.setUpdatedRange ?? (() => {});
337 const active = !!(r?.from || r?.to);
338 return (
339 <HeaderWithFilter
340 title="更新日時"
341 active={active}
342 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]"
343 trailing={
344 <SortButton
345 column={ctx.column}
346 aria-label="更新日時でソート"
347 title="更新日時でソート"
348 />
349 }
350 >
351 <DateRangePicker label="更新日時" value={r} onChange={setR} />
352 </HeaderWithFilter>
353 );
354 },
355 size: 120,
356 cell: ({ row }) => fmt(row.original.updatedAt),
357 },
358
359 // 検索用の隠し列
360 {
361 id: "q",
362 accessorFn: (r) =>
363 `${r.displayId} ${r.code} ${r.nameEffective} ${r.remarks ?? ""}`.toLowerCase(),
364 enableHiding: true,
365 enableSorting: false,
366 size: 0,
367 header: () => null,
368 cell: () => null,
369 },
370];
列構成はユーザ一覧とほぼ同じですが、ロール特有の 種別(kind)・実効表示名/色・権限フラグ を追加しています。
編集リンクは
編集リンクは
displayId
の接頭語(RL/DR)に依らず、常に /masters/roles/[displayId]
を踏むだけでOKです。データテーブルの作成
tsx
1// src/app/(protected)/masters/roles/data-table.tsx
2"use client";
3
4import * as React from "react";
5import type {
6 ColumnDef,
7 SortingState,
8 VisibilityState,
9} from "@tanstack/react-table";
10import {
11 flexRender,
12 getCoreRowModel,
13 getPaginationRowModel,
14 getSortedRowModel,
15 useReactTable,
16} from "@tanstack/react-table";
17import { Table } from "@/components/datagrid/table-container";
18import {
19 TableBody,
20 TableCell,
21 TableHead,
22 TableHeader,
23 TableRow,
24} from "@/components/ui/table";
25import type { DateRange } from "react-day-picker";
26import { format } from "date-fns";
27import { ja } from "date-fns/locale";
28import type { DepartmentRoleRow } from "./columns";
29
30import { useDatagridQueryState } from "@/lib/datagrid/use-datagrid-query-state";
31import { usePersistentDatagridState } from "@/lib/datagrid/use-persistent-datagrid-state";
32import { fromDateRange, toDateRange } from "@/lib/datagrid/date-io";
33import { buildCsv, downloadCsv, fmtDateTime } from "@/lib/datagrid/csv";
34
35import { DatagridToolbar } from "@/components/datagrid/datagrid-toolbar";
36import { DatagridSummary } from "@/components/datagrid/datagrid-summary";
37import { DatagridPagination } from "@/components/datagrid/datagrid-pagination";
38
39type Props = {
40 columns: ColumnDef<DepartmentRoleRow, unknown>[];
41 data: DepartmentRoleRow[];
42 kindOptions: { value: string; label: string }[];
43 canDownloadData?: boolean;
44 canEditData?: boolean;
45};
46
47export default function DataTable({
48 columns,
49 data,
50 kindOptions,
51 canDownloadData = false,
52 canEditData = false,
53}: Props) {
54 const [mounted, setMounted] = React.useState(false);
55 React.useEffect(() => setMounted(true), []);
56
57 const allColumnIds = React.useMemo(
58 () =>
59 [
60 "displayId",
61 "code",
62 "kind",
63 "nameEffective",
64 "priority",
65 "canEditData",
66 "canDownloadData",
67 "isEnabledInDepartment",
68 "remarks",
69 "createdAt",
70 "updatedAt",
71 ] as const,
72 [],
73 );
74 type ColId = (typeof allColumnIds)[number];
75
76 // URL同期(ロール一覧専用の名前空間)
77 const [queryState, setQueryState] = useDatagridQueryState(
78 "masters-roles",
79 {
80 q: "",
81 kinds: [] as string[], // 空配列=全種別
82 status: "ALL" as "ALL" | "ACTIVE" | "INACTIVE",
83 createdRange: undefined as { from?: string; to?: string } | undefined,
84 updatedRange: undefined as { from?: string; to?: string } | undefined,
85 cols: Array.from(allColumnIds) as ColId[],
86 },
87 { persistKey: "masters-roles" },
88 );
89
90 const allKinds = React.useMemo(
91 () => kindOptions.map((o) => o.value),
92 [kindOptions],
93 );
94 const kindsForFilter = queryState.kinds.length ? queryState.kinds : allKinds;
95
96 const createdRange = React.useMemo(
97 () => toDateRange(queryState.createdRange),
98 [queryState.createdRange],
99 );
100 const updatedRange = React.useMemo(
101 () => toDateRange(queryState.updatedRange),
102 [queryState.updatedRange],
103 );
104
105 const setQ = (v: string) => setQueryState((s) => ({ ...s, q: v }));
106 const setKinds = (next: string[]) =>
107 setQueryState((s) => ({ ...s, kinds: next }));
108 const setStatus = (next: "ALL" | "ACTIVE" | "INACTIVE") =>
109 setQueryState((s) => ({ ...s, status: next }));
110 const setCreatedRange = (r?: DateRange) =>
111 setQueryState((s) => ({ ...s, createdRange: fromDateRange(r) }));
112 const setUpdatedRange = (r?: DateRange) =>
113 setQueryState((s) => ({ ...s, updatedRange: fromDateRange(r) }));
114 const setVisibleColumnIds = (ids: ColId[]) =>
115 setQueryState((s) => ({ ...s, cols: ids }));
116
117 // ページサイズの永続化
118 const [persisted, setPersisted] = usePersistentDatagridState(
119 "masters-roles",
120 { pageSize: 20 },
121 );
122
123 const [sorting, setSorting] = React.useState<SortingState>([
124 { id: "priority", desc: false }, // 既定は優先度の昇順
125 ]);
126
127 const columnLabels = React.useMemo(
128 () =>
129 ({
130 displayId: "表示ID",
131 code: "コード",
132 kind: "種別",
133 nameEffective: "表示名",
134 priority: "優先度",
135 canEditData: "編集可",
136 canDownloadData: "DL可",
137 isEnabledInDepartment: "状態",
138 remarks: "備考",
139 createdAt: "登録日時",
140 updatedAt: "更新日時",
141 }) as const,
142 [],
143 );
144
145 const effectiveVisibleColumnIds: ColId[] = mounted
146 ? (queryState.cols as ColId[])
147 : (Array.from(allColumnIds) as ColId[]);
148 const columnVisibility = React.useMemo<VisibilityState>(() => {
149 const set = new Set(effectiveVisibleColumnIds);
150 return {
151 actions: true,
152 q: false,
153 displayId: set.has("displayId"),
154 code: set.has("code"),
155 kind: set.has("kind"),
156 nameEffective: set.has("nameEffective"),
157 priority: set.has("priority"),
158 canEditData: set.has("canEditData"),
159 canDownloadData: set.has("canDownloadData"),
160 isEnabledInDepartment: set.has("isEnabledInDepartment"),
161 remarks: set.has("remarks"),
162 createdAt: set.has("createdAt"),
163 updatedAt: set.has("updatedAt"),
164 };
165 }, [effectiveVisibleColumnIds]);
166
167 // フィルタ
168 const filteredData = React.useMemo(() => {
169 const needle = queryState.q.trim().toLowerCase();
170 const kindSet = new Set(kindsForFilter);
171 const inRange = (d: Date, r?: DateRange) => {
172 if (!r?.from && !r?.to) return true;
173 const ts = d.getTime();
174 if (r?.from && ts < new Date(r.from).setHours(0, 0, 0, 0)) return false;
175 if (r?.to && ts > new Date(r.to).setHours(23, 59, 59, 999)) return false;
176 return true;
177 };
178
179 return data.filter((r) => {
180 const passQ =
181 !needle ||
182 `${r.displayId} ${r.code} ${r.nameEffective} ${r.remarks ?? ""}`
183 .toLowerCase()
184 .includes(needle);
185 const passKind = kindSet.has(r.kind);
186 const passStatus =
187 queryState.status === "ALL" ||
188 (queryState.status === "ACTIVE"
189 ? r.isEnabledInDepartment
190 : !r.isEnabledInDepartment);
191 const passCreated = inRange(r.createdAt, createdRange);
192 const passUpdated = inRange(r.updatedAt, updatedRange);
193 return passQ && passKind && passStatus && passCreated && passUpdated;
194 });
195 }, [
196 data,
197 queryState.q,
198 queryState.status,
199 kindsForFilter,
200 createdRange,
201 updatedRange,
202 ]);
203
204 const table = useReactTable({
205 data: filteredData,
206 columns,
207 state: { sorting, columnVisibility },
208 onSortingChange: setSorting,
209 getCoreRowModel: getCoreRowModel(),
210 getSortedRowModel: getSortedRowModel(),
211 getPaginationRowModel: getPaginationRowModel(),
212 initialState: { pagination: { pageIndex: 0, pageSize: 20 } },
213 meta: {
214 kindOptions,
215 kinds: kindsForFilter,
216 setKinds,
217 status: queryState.status,
218 setStatus,
219 createdRange,
220 setCreatedRange,
221 updatedRange,
222 setUpdatedRange,
223 },
224 });
225
226 React.useEffect(() => {
227 if (mounted) table.setPageSize(persisted.pageSize);
228 }, [mounted, persisted.pageSize, table]);
229
230 // CSV(可視列のみ)
231 const onDownloadCsv = React.useCallback(() => {
232 const visibleLeaf = table
233 .getVisibleLeafColumns()
234 .map((c) => c.id)
235 .filter((id) => id !== "actions" && id !== "q") as ColId[];
236
237 const headers = visibleLeaf.map((id) => columnLabels[id]);
238
239 const rows = filteredData.map((r) =>
240 visibleLeaf.map((id) => {
241 switch (id) {
242 case "displayId":
243 return r.displayId;
244 case "code":
245 return r.code;
246 case "kind":
247 return r.kind === "role"
248 ? "ベース"
249 : r.kind === "override"
250 ? "上書き"
251 : "部署ローカル";
252 case "nameEffective":
253 return r.nameEffective;
254 case "priority":
255 return r.priority;
256 case "canEditData":
257 return r.canEditData ? "可" : "不可";
258 case "canDownloadData":
259 return r.canDownloadData ? "可" : "不可";
260 case "isEnabledInDepartment":
261 return r.isEnabledInDepartment ? "有効" : "無効";
262 case "remarks":
263 return r.remarks ?? "";
264 case "createdAt":
265 return fmtDateTime(r.createdAt);
266 case "updatedAt":
267 return fmtDateTime(r.updatedAt);
268 default:
269 return "";
270 }
271 }),
272 );
273
274 const csv = buildCsv(headers, rows);
275 const ts = format(new Date(), "yyyyMMdd_HHmmss", { locale: ja });
276 downloadCsv(`roles_${ts}.csv`, csv);
277 }, [filteredData, table, columnLabels]);
278
279 const kindText =
280 queryState.kinds.length === 0
281 ? "種別: すべて"
282 : `種別: ${queryState.kinds
283 .map(
284 (v) =>
285 new Map(kindOptions.map((o) => [o.value, o.label])).get(v) ?? v,
286 )
287 .join(", ")}`;
288 const statusText =
289 queryState.status === "ALL"
290 ? "状態: すべて"
291 : queryState.status === "ACTIVE"
292 ? "状態: 有効"
293 : "状態: 無効";
294 const visibleColsText = (
295 mounted ? effectiveVisibleColumnIds : (Array.from(allColumnIds) as ColId[])
296 )
297 .map((id) => columnLabels[id])
298 .join(", ");
299
300 const filteredCount = filteredData.length;
301
302 return (
303 <div className="space-y-3">
304 <DatagridToolbar<ColId>
305 q={queryState.q}
306 onChangeQ={setQ}
307 columnOptions={allColumnIds.map((id) => ({
308 value: id,
309 label: columnLabels[id],
310 }))}
311 visibleColumnIds={effectiveVisibleColumnIds}
312 onChangeVisibleColumns={setVisibleColumnIds}
313 canDownloadData={canDownloadData}
314 onDownloadCsv={onDownloadCsv}
315 canEditData={canEditData}
316 newHref="/masters/roles/new"
317 />
318
319 <div className="flex items-center justify-between gap-3">
320 <div className="text-sm" data-testid="count">
321 表示件数: {filteredCount} 件
322 </div>
323 <div className="flex max-w-[60%] items-center justify-end gap-2">
324 <DatagridSummary
325 mounted={mounted}
326 roleText={kindText}
327 statusText={statusText}
328 createdRangeISO={queryState.createdRange}
329 updatedRangeISO={queryState.updatedRange}
330 createdRange={createdRange}
331 updatedRange={updatedRange}
332 visibleColsText={visibleColsText}
333 />
334 <button
335 type="button"
336 className="text-muted-foreground shrink-0 cursor-pointer text-xs underline"
337 onClick={() => {
338 setQueryState((s) => ({
339 ...s,
340 q: "",
341 kinds: [],
342 status: "ALL",
343 createdRange: undefined,
344 updatedRange: undefined,
345 cols: Array.from(allColumnIds) as ColId[],
346 }));
347 setPersisted((p) => ({ ...p, pageSize: 20 }));
348 table.setPageSize(20);
349 }}
350 title="全フィルタ解除"
351 >
352 全フィルタ解除
353 </button>
354 </div>
355 </div>
356
357 <div className="overflow-x-auto rounded-md border pb-1">
358 <Table
359 className="w-full"
360 data-testid="roles-table"
361 containerClassName={
362 !canDownloadData && !canEditData
363 ? "max-h-[calc(100svh_-_244px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1"
364 : "max-h-[calc(100svh_-_284px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1"
365 }
366 >
367 <TableHeader className="bg-muted/60 supports-[backdrop-filter]:bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur">
368 {table.getHeaderGroups().map((hg) => (
369 <TableRow key={hg.id}>
370 {hg.headers.map((header) => (
371 <TableHead
372 key={header.id}
373 style={{ width: header.column.getSize() }}
374 >
375 {header.isPlaceholder
376 ? null
377 : flexRender(
378 header.column.columnDef.header,
379 header.getContext(),
380 )}
381 </TableHead>
382 ))}
383 </TableRow>
384 ))}
385 </TableHeader>
386 <TableBody>
387 {table.getRowModel().rows.length ? (
388 table.getRowModel().rows.map((row) => (
389 <TableRow
390 key={row.id}
391 data-testid={`row-${(row.original as DepartmentRoleRow).displayId}`}
392 >
393 {row.getVisibleCells().map((cell) => (
394 <TableCell
395 key={cell.id}
396 style={{ width: cell.column.getSize() }}
397 >
398 {flexRender(
399 cell.column.columnDef.cell,
400 cell.getContext(),
401 )}
402 </TableCell>
403 ))}
404 </TableRow>
405 ))
406 ) : (
407 <TableRow>
408 <TableCell
409 colSpan={table.getAllColumns().length}
410 className="text-muted-foreground py-10 text-center text-sm"
411 >
412 条件に一致するロールが見つかりませんでした。
413 </TableCell>
414 </TableRow>
415 )}
416 </TableBody>
417 </Table>
418 </div>
419
420 <DatagridPagination<DepartmentRoleRow>
421 table={table}
422 pageSize={table.getState().pagination.pageSize}
423 onChangePageSize={(n) => {
424 table.setPageSize(n);
425 setPersisted((p) => ({ ...p, pageSize: n }));
426 }}
427 />
428 </div>
429 );
430}
DataTable はユーザ一覧をほぼ踏襲しつつ、URL 名前空間を
CSV は可視列のみを出力し、ファイル名は
masters-roles
に変更、種別(kind) フィルタ を追加しました。CSV は可視列のみを出力し、ファイル名は
roles_YYYYMMDD_HHmmss.csv
です。Pageファイルの作成
tsx
1// src/app/(protected)/masters/roles/page.tsx
2import type { Metadata } from "next";
3import { SidebarTrigger } from "@/components/ui/sidebar";
4import {
5 Breadcrumb,
6 BreadcrumbItem,
7 BreadcrumbLink,
8 BreadcrumbList,
9 BreadcrumbPage,
10 BreadcrumbSeparator,
11} from "@/components/ui/breadcrumb";
12import { Separator } from "@/components/ui/separator";
13import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
14import { prisma } from "@/lib/database";
15import DataTable from "./data-table";
16import { columns, type DepartmentRoleRow } from "./columns";
17
18export const metadata: Metadata = {
19 title: "ロール一覧",
20 description:
21 "部署視点でのロール一覧(Role + DepartmentRole)を混在表示。検索/フィルタ/CSV対応",
22};
23
24export default async function Page() {
25 // 1) SSRガード(viewer には userId / 権限が入る)
26 const viewer = await guardHrefOrRedirect("/masters/roles", "/");
27
28 // 2) 自部署ID
29 const me = await prisma.user.findUnique({
30 where: { id: viewer.userId },
31 select: { departmentId: true },
32 });
33 if (!me?.departmentId) return null;
34
35 // 3) ベースRole(当該部署の override を1件だけ同時取得)
36 const base = await prisma.role.findMany({
37 where: { isActive: true },
38 orderBy: { priority: "asc" },
39 select: {
40 displayId: true,
41 code: true,
42 name: true,
43 badgeColor: true,
44 priority: true,
45 canEditData: true,
46 canDownloadData: true,
47 createdAt: true,
48 updatedAt: true,
49 departmentRoles: {
50 where: { departmentId: me.departmentId, roleId: { not: null } },
51 take: 1,
52 select: {
53 displayId: true,
54 isEnabled: true,
55 nameOverride: true,
56 badgeColorOverride: true,
57 },
58 },
59 },
60 });
61
62 // 4) 部署カスタム(roleId == null のみ)
63 const customs = await prisma.departmentRole.findMany({
64 where: { departmentId: me.departmentId, roleId: null },
65 orderBy: { priority: "asc" },
66 select: {
67 displayId: true,
68 code: true,
69 name: true,
70 badgeColor: true,
71 priority: true,
72 canEditData: true,
73 canDownloadData: true,
74 isEnabled: true,
75 remarks: true,
76 createdAt: true,
77 updatedAt: true,
78 },
79 });
80
81 // 5) UI行へ整形(kind / 実効表示名・色を組み立て)
82 const fromRole: DepartmentRoleRow[] = base.map((r) => {
83 const ov = r.departmentRoles[0];
84 return {
85 displayId: r.displayId,
86 code: r.code,
87 kind: ov ? "override" : "role",
88 nameEffective: ov?.nameOverride ?? r.name,
89 badgeColorEffective: ov?.badgeColorOverride ?? r.badgeColor ?? null,
90 priority: r.priority,
91 canEditData: r.canEditData,
92 canDownloadData: r.canDownloadData,
93 isEnabledInDepartment: ov ? ov.isEnabled : true,
94 remarks: null,
95 createdAt: r.createdAt,
96 updatedAt: r.updatedAt,
97 };
98 });
99
100 const fromCustom: DepartmentRoleRow[] = customs.map((dr) => ({
101 displayId: dr.displayId,
102 code: dr.code ?? "",
103 kind: "custom",
104 nameEffective: dr.name ?? "",
105 badgeColorEffective: dr.badgeColor ?? null,
106 priority: dr.priority ?? 0,
107 canEditData: dr.canEditData ?? false,
108 canDownloadData: dr.canDownloadData ?? false,
109 isEnabledInDepartment: dr.isEnabled,
110 remarks: dr.remarks ?? null,
111 createdAt: dr.createdAt,
112 updatedAt: dr.updatedAt,
113 }));
114
115 const rows: DepartmentRoleRow[] = [...fromRole, ...fromCustom];
116
117 // 6) 種別フィルタ用の選択肢(固定3種)
118 const kindOptions = [
119 { value: "role", label: "ベース" },
120 { value: "override", label: "上書き" },
121 { value: "custom", label: "部署ローカル" },
122 ] as const;
123
124 return (
125 <>
126 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
127 <div className="flex items-center gap-2 px-4">
128 <SidebarTrigger className="-ml-1" />
129 <Separator
130 orientation="vertical"
131 className="mr-2 data-[orientation=vertical]:h-4"
132 />
133 <Breadcrumb>
134 <BreadcrumbList>
135 <BreadcrumbItem className="hidden md:block">
136 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink>
137 </BreadcrumbItem>
138 <BreadcrumbSeparator className="hidden md:block" />
139 <BreadcrumbItem>
140 <BreadcrumbPage>ロール一覧</BreadcrumbPage>
141 </BreadcrumbItem>
142 </BreadcrumbList>
143 </Breadcrumb>
144 </div>
145 </header>
146
147 <div className="w-full max-w-[1729px] p-4 pt-0">
148 <DataTable
149 columns={columns}
150 data={rows}
151 kindOptions={
152 kindOptions as unknown as { value: string; label: string }[]
153 }
154 canDownloadData={viewer.canDownloadData}
155 canEditData={viewer.canEditData}
156 />
157 </div>
158 </>
159 );
160}
上記では、
Role
と DepartmentRole(custom)
を別々に取得し、一覧用 Row 型 DepartmentRoleRow
に正規化してから結合しています。override
が存在するベースロールは kind: "override"
として表示し、表示名/色/有効 を上書き値で合成します。動作確認
- 初期表示:
/masters/roles
を開くと、部署視点での ベース + 上書き + カスタム が混在表示されます。 - フィルタ
- ヘッダの「種別」で ベース/上書き/部署ローカル を複数選択で絞り込み。
- 「状態」で 有効/無効 を切り替え。
- キーワード・日付レンジ(登録/更新)もユーザ一覧と同様に動作。
- 編集:行頭のアイコンから
/masters/roles/[displayId]
へ遷移(RL は上書き編集、DR はcustom編集)。 - CSV:可視列のみ出力。badge 色や状態などは文字列化されます。
- 権限制御:
canEditData
で 「新規登録」 ボタンの表示切替、canDownloadData
で CSV ボタンの表示切替。
これで、テナント側の認識に沿った 唯一のロール一覧 が完成です。ユーザ一覧と同じ操作感で運用でき、今後の拡張(列追加・フィルタ拡張)も同じパターンで進められます。
4. まとめと次回予告
今回の記事では、前編で導入した DepartmentRoleテーブル を管理画面から安全に操作できるようにしました。
新規登録・更新・一覧を実装し、部署ごとのロール運用が実務的に可能な状態になりました。
新規登録・更新・一覧を実装し、部署ごとのロール運用が実務的に可能な状態になりました。
今回実装した機能
今回取り組んだのは、以下の3つの機能です。 それぞれ、Server Action と UI を組み合わせて構築しました。
txt
1[今回完成した流れ]
2
3新規登録(DepartmentRole.custom)
4 ↓
5更新(DepartmentRole.update / override 作成・編集)
6 ↓
7一覧(Role + DepartmentRole を統合表示)
機能ごとの成果を表に整理すると次のようになります。
機能区分 | 対象 | 実現内容 |
---|---|---|
新規登録 | custom 専用 | 部署独自ロールを作成。priority ≤ 99 や code重複をUI/DBで二重チェック。 |
更新 | custom / override | customは通常編集、overrideはRoleを上書き。権限なしユーザは参照専用に制御。 |
一覧 | role/custom混在 | RoleとDepartmentRoleを統合表示。種別・状態・検索・CSV出力に対応。 |
- 部署管理者は「ロール一覧画面がすべて」という認識で運用可能になった
- 一覧に Role と DepartmentRole が同居することで、override/custom を意識せず直感的に操作できる
- 権限がないユーザは参照のみ可能となり、誤操作や越権操作を防げる
次回予告:ユーザ管理と実効ロールの統合
次回は、完成した DepartmentRole の仕組みを ユーザ管理画面へ統合 します。
現状、ユーザ登録・更新・一覧・プロフィール表示では
現状、ユーザ登録・更新・一覧・プロフィール表示では
Role
テーブルの情報を直接参照していますが、これを 実効ロール(Role + DepartmentRole) に切り替えます。次回「管理画面フォーマット開発編 #9」では、このリファクタリングを詳細に扱い、 ユーザ登録・更新・参照のすべてで「実効ロール」が正しく反映される状態を目指します。
参考文献
この記事で利用したライブラリや参考にした公式ドキュメントをまとめます。
実装の正確性・再現性を担保するため、バージョンや用途を明記しています。
実装の正確性・再現性を担保するため、バージョンや用途を明記しています。
UI / フロントエンド関連
ライブラリ | URL | 用途 |
---|---|---|
React | https://react.dev/ | UI 構築の基盤 |
Next.js | https://nextjs.org/docs | App Router, SSR/SA 機能の利用 |
shadcn/ui | https://ui.shadcn.com/ | Radix ベースの UI コンポーネント |
Tailwind CSS | https://tailwindcss.com/docs | ユーティリティファースト CSS |
フォーム / バリデーション関連
ライブラリ | URL | 用途 |
---|---|---|
React Hook Form | https://react-hook-form.com/ | フォーム管理、入力制御 |
Zod | https://zod.dev/ | スキーマ定義と実行時バリデーション |
@hookform/resolvers/zod | https://react-hook-form.com/docs/useform/#resolver | RHF と Zod の統合 |
データアクセス / バックエンド関連
ライブラリ | URL | 用途 |
---|---|---|
Prisma | https://www.prisma.io/docs | ORM、DB アクセス、スキーマと型の自動生成 |
PostgreSQL | https://www.postgresql.org/docs/ | DB。priority 制約や XOR 制約を含む |
データグリッド / 一覧表示関連
ライブラリ | URL | 用途 |
---|---|---|
@tanstack/react-table | https://tanstack.com/table/v8/docs/guide/introduction | DataTable 実装の基盤 |
date-fns | https://date-fns.org/ | 日付処理、フォーマット |
react-day-picker | https://react-day-picker.js.org/ | 日付レンジフィルタ UI |
本記事に関連する DELOGs 内記事
以上の資料を参考に、DepartmentRole の 新規登録・更新・一覧表示 を UI/Server Action 両面から実装しました。
次回の記事では、ユーザ管理画面に「実効ロール」を統合していきます。
次回の記事では、ユーザ管理画面に「実効ロール」を統合していきます。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #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)
[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装する
これまでメニュー表示に適用していたRBACを、各ページのアクセス制御に拡張
2025/9/23公開
![[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-rbac-guard%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #5] ユーザプロフィール更新
プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装
2025/9/21公開
![[管理画面フォーマット開発編 #5] ユーザプロフィール更新のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-profile%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示
ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築
2025/9/16公開
![[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-avatar-upload%2Fhero-thumbnail.jpg&w=1200&q=75)