DELOGs
[管理画面フォーマット開発編 #9 後編] 部署別ロール対応 ─ プロフィール管理の改修

管理画面フォーマット開発編 #9 後編
部署別ロール対応 ─ プロフィール管理の改修

DepartmentRole導入に伴い、プロフィール管理で「実効ロール」を参照するように修正と一部ついでの変更

初回公開日

最終更新日

0. はじめに

今回の記事では、管理画面フォーマット開発編の部署別ロール対応の続きとして、プロフィール管理の改修を取り上げます。
前回記事 「【管理画面フォーマット開発編 #9 前編】 部署別ロール対応 ─ ユーザ管理の改修」 までに部署単位で権限を持つ DepartmentRole テーブルを導入し、ユーザ作成・編集画面などの基本機能を更新しました。しかし、プロフィール画面(ログイン中の本人情報表示)だけは、旧構成のまま Role テーブル依存となっており、実際の運用とは齟齬が生じています。
そこで今回は、以下の3点を中心に改修を行います。
  1. 実効ロール(effectiveRole)対応
    モック依存のロール表示を廃止し、AuthUserSnapshot に格納される実際の部署別ロール情報を表示に反映します。
  2. Phone欄の追加とフォーム統一
    Userテーブルで追加された電話番号カラムをプロフィール編集フォームに統合し、氏名・電話・アバターをまとめて更新できるようにします。
  3. メール変更申請一覧の再構築
    管理者が承認・却下できる「メール変更申請一覧」を新たに設置し、ユーザ一覧やロール一覧と同等のフィルタ・データテーブル仕様へ統一します。
これらに加え、すべての更新処理を 「本人のみが操作できる構成」 に見直し、セキュリティ面の安全性を確保しました。
本記事は、これまでに構築したユーザ管理・ロール管理の仕組みを踏まえ、プロフィール画面を現実的な運用レベルに仕上げる調整となります。

読み進める前に

本記事は「管理画面フォーマット開発編」の一部として進めています。
前回の記事までで DepartmentRole のDB設計とUI操作 が完成していることを前提としています。
まだ読んでいない方は、下記を先に参照してください。

1. 改修対象の整理 ─ プロフィールまわりの課題点

プロフィール画面は、ユーザが自分の情報を確認・更新するための中核的なページですが、DepartmentRole導入後も旧構成のままで動作しており、いくつかの不整合が生じていました。ここでは、主な課題を整理しておきます。
まず大きな違いは、ロール情報の参照元です。これまでのプロフィールは Role テーブルのみを前提としており、部署ごとに異なる権限や名称を反映できませんでした。そのため、DepartmentRoleによる上書き設定が行われても、プロフィール上では旧名称・旧権限のまま表示されてしまうという問題がありました。
さらに、開発初期の構成を引きずって、いくつかの項目ではモックデータを使った暫定実装が残っていました。特に getRoleBadgeProps() によるロールバッジ表示は、実データを無視して固定値を出力する状態だったため、今回の改修で完全に除去する必要がありました。
また、Userテーブルに追加された phone カラムに対応しておらず、本人の電話番号を確認・変更できない点も実務上の問題でした。
同様に、メール変更申請は本人申請までの機能しかなく、承認側の画面はモックデータを使った未完成のままでした。これでは管理者が変更申請を処理できません。
こうした状態を一覧でまとめると、次のようになります。
区分現状の問題点改修方針
ロール表示Role テーブルのみ参照。部署ごとの上書き(DepartmentRole)が反映されない。AuthUserSnapshoteffectiveRole(実効ロール)を参照し、部署別ロールを正しく表示。
モック依存getRoleBadgeProps() などダミーデータを使用。すべて削除し、実際のスナップショット情報を表示に使用。
Phone欄User.phone が追加されたがフォーム未対応。フォームに電話番号入力欄を追加し、RHF+Zodで双方向バインド。
メール変更申請一覧モックデータ表示のみで、承認・却下機能なし。EmailChangeRequest テーブルと連携し、フィルタ・ページング付きの一覧を新設。
セキュリティ一部Actionでユーザ識別が曖昧。lookupSessionFromCookie() を起点に、すべて「本人限定」更新へ統一。
これらの課題はどれも個別には小さな修正に見えますが、まとめて対応することで、プロフィール画面全体がDepartmentRole対応の「本番仕様」に進化します。
次章では、この改修方針に基づいてUI・スキーマ・サーバアクションをどのように調整したのかを詳しく見ていきます。

2. プロフィール画面の改修内容

プロフィール画面は、DepartmentRole 導入に合わせて「実効ロールの表示」「Phone項目の追加」「SSR構成の統一」「モック依存の排除」を行いました。
スナップショットは最小限のまま維持し、表示専用の情報(実効ロール名/バッジ色/電話番号/アバターURL)は サーバアクションで都度取得 する方針です。

サーバアクション新設 ─ 実効ロール名・色+電話番号をDBから取得

表示専用の情報をスナップショットへ追加せず、都度DBから集約 するためにサーバアクションを新設しました。
departmentRoleId があれば部署ロール、なければグローバルロール経由で getEffectiveRole() を用いて名称と色を解決します。
ts
1// src/app/_actions/profile/get-profile-detail.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { getEffectiveRole } from "@/lib/auth/effective-role"; 7 8export type MyProfileDetail = { 9 name: string; 10 email: string; 11 phone: string | null; 12 currentAvatarUrl: string | null; 13 effectiveRoleName: string; 14 effectiveBadgeColor: string | null; 15}; 16 17export async function getMyProfileDetail() { 18 const session = await lookupSessionFromCookie(); 19 if (!session.ok) return { ok: false, message: "未ログインです" }; 20 21 const me = await prisma.user.findUnique({ 22 where: { id: session.userId }, 23 select: { 24 id: true, 25 name: true, 26 email: true, 27 phone: true, 28 avatar: true, 29 departmentId: true, 30 roleId: true, 31 departmentRoleId: true, 32 }, 33 }); 34 if (!me) return { ok: false, message: "ユーザーが見つかりません" }; 35 36 const eff = await getEffectiveRole( 37 me.departmentRoleId 38 ? { departmentId: me.departmentId, departmentRoleId: me.departmentRoleId } 39 : { departmentId: me.departmentId, roleId: me.roleId! }, 40 ); 41 if (!eff) return { ok: false, message: "実効ロールの取得に失敗しました" }; 42 43 return { 44 ok: true, 45 value: { 46 name: me.name, 47 email: me.email, 48 phone: me.phone ?? null, 49 currentAvatarUrl: me.avatar ? `/avatar/${me.id}` : null, 50 effectiveRoleName: eff.name, 51 effectiveBadgeColor: eff.badgeColor ?? null, 52 }, 53 }; 54}
これにより、スナップショットを肥大化させることなく、表示専用の情報だけをサーバ側で安全に集約できます。

スキーマの整合 ─ profileUpdateSchema に phone を追加

クライアント/サーバで同じバリデーションを使うため、profileUpdateSchemaphone を追加します。
また、File は Node 環境で未定義になり得るため、環境分岐 で安全に取り扱います。
ts
1/** ── プロフィール(本人用): displayId は UI に出さない。role は「表示のみ」 ── */ 2export const profileUpdateSchema = z.object({ 3 name: nameSchema, 4 phone: phoneSchema, // 追加 5 avatarFile: z 6 .instanceof(File) 7 .optional() 8 .refine( 9 (file) => 10 !file || 11 ["image/png", "image/jpeg", "image/webp", "image/gif"].includes( 12 file.type, 13 ), 14 "画像は png / jpeg / webp / gif のいずれかにしてください", 15 ) 16 .refine( 17 (file) => !file || file.size <= MAX_IMAGE_MB * 1024 * 1024, 18 `画像サイズは ${MAX_IMAGE_MB}MB 以下にしてください`, 19 ), 20});
phoneSchemaはすでにユーザ登録で作成済みなので、profileUpdateSchemaにこれを割り当ててphoneを追加します。

SSR構成への統一 ─ page.tsx の改修

次に、他ページと同様に page.tsx 側で必要データをSSR取得し、初期データを Client コンポーネントへ渡す構成に変更しました。
tsx
1// src/app/(protected)/profile/page.tsx 2import type { Metadata } from "next"; 3import { 4 Breadcrumb, 5 BreadcrumbItem, 6 BreadcrumbLink, 7 BreadcrumbList, 8 BreadcrumbPage, 9 BreadcrumbSeparator, 10} from "@/components/ui/breadcrumb"; 11import { Separator } from "@/components/ui/separator"; 12import { SidebarTrigger } from "@/components/ui/sidebar"; 13import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 14import { getMyProfileDetail } from "@/app/_actions/profile/get-profile-detail"; // 追加 15import Client from "./client"; 16 17export const metadata: Metadata = { 18 title: "プロフィール", 19 description: 20 "ユーザのプロフィール(氏名・アバター)を編集し、メール/パスワード変更画面へ遷移", 21}; 22 23export default async function Page() { 24 await guardHrefOrRedirect("/profile", "/"); 25 26 // ★ ここで実効ロールと電話番号取得 27 const res = await getMyProfileDetail(); 28 if (!res.ok || !res.value) return null; 29 30 return ( 31 <> 32 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 33 <div className="flex items-center gap-2 px-4"> 34 <SidebarTrigger className="-ml-1" /> 35 <Separator 36 orientation="vertical" 37 className="mr-2 data-[orientation=vertical]:h-4" 38 /> 39 <Breadcrumb> 40 <BreadcrumbList> 41 <BreadcrumbItem className="hidden md:block"> 42 <BreadcrumbLink href="/profile">プロフィール</BreadcrumbLink> 43 </BreadcrumbItem> 44 <BreadcrumbSeparator className="hidden md:block" /> 45 <BreadcrumbItem> 46 <BreadcrumbPage>プロフィール編集</BreadcrumbPage> 47 </BreadcrumbItem> 48 </BreadcrumbList> 49 </Breadcrumb> 50 </div> 51 </header> 52 53 {/* クライアントコンポーネントへ実効ロール等を提供 */} 54 <div className="max-w-xl p-4 pt-0"> 55 <Client initial={res.value} /> 56 </div> 57 </> 58 ); 59}

Client の責務整理 ─ 初期値はSSRから、更新はスナップショット再取得

初期表示は SSR の初期値を使い、更新時のみ refreshAuthSnapshotAction() で Context を再同期します。
updateProfileAction は従来どおり FormData 送信ですが、Phone も送る ように変更します。
tsx
1// src/app/(protected)/profile/page.tsx 2import type { Metadata } from "next"; 3import { 4 Breadcrumb, 5 BreadcrumbItem, 6 BreadcrumbLink, 7 BreadcrumbList, 8 BreadcrumbPage, 9 BreadcrumbSeparator, 10} from "@/components/ui/breadcrumb"; 11import { Separator } from "@/components/ui/separator"; 12import { SidebarTrigger } from "@/components/ui/sidebar"; 13import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 14import { getMyProfileDetail } from "@/app/_actions/profile/get-profile-detail"; // 追加 15import Client from "./client"; 16 17export const metadata: Metadata = { 18 title: "プロフィール", 19 description: 20 "ユーザのプロフィール(氏名・アバター)を編集し、メール/パスワード変更画面へ遷移", 21}; 22 23export default async function Page() { 24 await guardHrefOrRedirect("/profile", "/"); 25 26 // ★ ここで実効ロールと電話番号取得 27 const res = await getMyProfileDetail(); 28 if (!res.ok || !res.value) return null; 29 30 return ( 31 <> 32 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 33 <div className="flex items-center gap-2 px-4"> 34 <SidebarTrigger className="-ml-1" /> 35 <Separator 36 orientation="vertical" 37 className="mr-2 data-[orientation=vertical]:h-4" 38 /> 39 <Breadcrumb> 40 <BreadcrumbList> 41 <BreadcrumbItem className="hidden md:block"> 42 <BreadcrumbLink href="/profile">プロフィール</BreadcrumbLink> 43 </BreadcrumbItem> 44 <BreadcrumbSeparator className="hidden md:block" /> 45 <BreadcrumbItem> 46 <BreadcrumbPage>プロフィール編集</BreadcrumbPage> 47 </BreadcrumbItem> 48 </BreadcrumbList> 49 </Breadcrumb> 50 </div> 51 </header> 52 53 {/* クライアントコンポーネントへ実効ロール等を提供 */} 54 <div className="max-w-xl p-4 pt-0"> 55 <Client initial={res.value} /> 56 </div> 57 </> 58 ); 59}

Form の修正 ─ 実効ロール表示+Phone 入力の追加

モック依存(getRoleBadgeProps など)を排除し、バッジはサーバアクションの返却値で描画します。
phone をフォームに追加し、zodResolver(profileUpdateSchema) で検証を統一しました。
ts
1// src/components/profile/profile-form.tsx(変更箇所抜粋) 2 3// ─ 冒頭省略 4 5// import type { Role } from "@/lib/roles/schema"; // 削除 6// import { getRoleBadgeProps } from "@/lib/roles/mock"; // 削除 7 8// ─ 省略 9 10export type ProfileInitial = { 11 name: string; 12 email: string; 13 phone?: string; // 追加 14 // roleCode: Role["code"]; // 削除 15 currentAvatarUrl?: string; 16 effectiveRoleName: string; 17 effectiveBadgeColor: string | null; 18}; 19 20// ─ 省略 21 22export default function ProfileForm({ 23 initial, 24 onSubmit, 25 onCancel, 26 onNavigateEmail, 27 onNavigatePassword, 28 onDelete, 29}: Props) { 30 const form = useForm<ProfileUpdateValues>({ 31 resolver: zodResolver(profileUpdateSchema), 32 defaultValues: { 33 name: initial.name, 34 avatarFile: undefined, 35 phone: initial.phone, // 追加 36 }, 37 mode: "onBlur", 38 }); 39 40// ─ 省略 41 42return ( 43 <Form {...form}> 44 <form onSubmit={handleSubmit} data-testid="profile-form"> 45 <Card className="w-full rounded-md"> 46 <CardHeader className="-mt-2 -mb-4"> 47 {/* 初期値を利用してラベル表示 */} 48 <Badge 49 className="ml-auto inline-block w-[85px] px-2 py-1 text-center" 50 style={{ 51 backgroundColor: initial.effectiveBadgeColor ?? "#666", 52 color: "#fff", 53 border: "none", 54 }} 55 > 56 {initial.effectiveRoleName} 57 </Badge> 58 {/* 削除 → <RoleBadgeRow label={badge.label} badgeStyle={badge.style} /> */} 59 </CardHeader> 60 61 <CardContent className="space-y-6 pt-1"> 62 <AvatarField 63 currentAvatarUrl={initial.currentAvatarUrl} 64 previewUrl={previewUrl} 65 onPick={async (file) => { 66 form.clearErrors("avatarFile"); 67 if (!file) { 68 form.setValue("avatarFile", undefined, { shouldDirty: true }); 69 setPreviewUrl(null); 70 return; 71 } 72 const pixelError = await validateImagePixels(file); 73 if (pixelError) { 74 form.setError("avatarFile", { 75 type: "validate", 76 message: pixelError, 77 }); 78 form.setValue("avatarFile", undefined, { shouldDirty: true }); 79 setPreviewUrl(null); 80 return; 81 } 82 form.setValue("avatarFile", file, { 83 shouldDirty: true, 84 shouldValidate: true, // zod の容量/拡張子チェックも走る 85 }); 86 setPreviewUrl(URL.createObjectURL(file)); 87 void form.trigger("avatarFile"); 88 }} 89 onClear={() => { 90 form.setValue("avatarFile", undefined, { shouldDirty: true }); 91 form.clearErrors("avatarFile"); 92 setPreviewUrl(null); 93 }} 94 onDelete={onDelete} // ← ここで渡す 95 footerMessage={<FormMessage data-testid="avatar-error" />} 96 /> 97 98 <NameField /> 99 <PhoneField /> {/* ← 追加 */} 100 <EmailRow email={initial.email} onNavigate={onNavigateEmail} /> 101 <PasswordRow onNavigate={onNavigatePassword} /> 102 </CardContent> 103 104// ─ 省略 105 106// 電話番号 (ファイル末尾に追加) 107function PhoneField() { 108 return ( 109 <FormField 110 name="phone" 111 render={({ field }) => ( 112 <FormItem> 113 <FormLabel className="font-semibold">電話番号</FormLabel> 114 <FormControl> 115 <Input 116 {...field} 117 value={field.value ?? ""} 118 placeholder="090-xxxx-xxxx" 119 aria-label="電話番号" 120 autoComplete="off" 121 data-testid="phone" 122 /> 123 </FormControl> 124 <FormMessage data-testid="phone-error" /> 125 </FormItem> 126 )} 127 /> 128 ); 129}

更新用サーバアクションをPhone対応に拡張

現行 client.tsxupdateProfileActiondeleteOwnAvatarActionsrc/app/_actions/profile/avatar.ts から import しています。
FormDataphone を追加したため、avatar.ts 側で phone を受け取り、DB更新へ反映 させます。
ts
1// src/app/_actions/profile/avatar.ts(変更箇所のみ抜粋) 2 3// ─省略 4 5export async function updateProfileAction( 6 formData: FormData, 7): Promise<ActionResult> { 8 // 1) 認証 9 const session = await lookupSessionFromCookie(); 10 if (!session.ok) return { ok: false, message: "認証が必要です" }; 11 12 // 2) クライアント側と同様のzodスキーマで name / phone / avatarFile を検証(UIの改ざん対策) 13 const name = String(formData.get("name") ?? ""); 14 const phoneRaw = formData.get("phone"); // ★追加 15 const avatarFile = formData.get("avatarFile"); 16 const input = { 17 name, 18 phone: typeof phoneRaw === "string" ? phoneRaw : undefined, // ★追加: 空なら undefined 19 avatarFile: avatarFile instanceof File ? avatarFile : undefined, 20 }; 21 22// ─省略 23 24 // 5) DB更新(トランザクション) 25 await prisma.$transaction(async (tx) => { 26 // 旧ファイル名の取得(新規保存があるときのみ) 27 if (newAvatarFileName) { 28 const current = await tx.user.findUnique({ 29 where: { id: session.userId }, 30 select: { avatar: true }, 31 }); 32 oldAvatarFileName = current?.avatar ?? null; 33 } 34 35 await tx.user.update({ 36 where: { id: session.userId }, 37 data: { 38 name: parsed.data.name, 39 phone: parsed.data.phone, // ★ 追加 40 ...(newAvatarFileName ? { avatar: newAvatarFileName } : {}), 41 }, 42 }); 43 }); 44 45// ─省略 46
これで、client.tsxFormDataphone を入れて送った場合でも、avatar.ts 側で正しく検証・更新されます。
Phone は任意項目のため、空文字は送らず undefined 扱いで更新しない運用にしています(UI側で未入力なら fd.set("phone", ...) を省略)。
以上で、プロフィール画面は DepartmentRole を正しく反映し、Phone 項目の入出力にも対応しました。
SSR 構成の統一により、他ページとの実装様式も揃っています。次章では、メール変更申請一覧の刷新へ進みます。

3. メール変更申請一覧の再構築

狙いは、ユーザ一覧で整えた DataGrid 体験(高機能フィルタ、URL/Storageへの状態保存、CSVダウンロード)を、メール変更申請一覧にも水平展開することです。あわせて、一覧上に「申請者の実効ロール(名称+バッジ色)」を表示し、承認・却下の運用判断をしやすくします。
方針はユーザ一覧に寄せます:
  • SSRで部署スコープの一覧データを構築 → Clientへ初期データを渡す
  • フィルタはURL(+sessionStorage)に保持、列表示も変更可能
  • CSVは可視列のみをエクスポート(日時は安定フォーマット)
  • 申請の承認/却下はサーバアクションで実行し、結果はローカル状態更新(再描画)

改修の目的と全体構成

改修の主な目的は以下の通りです。
txt
1┌───────────────────────────────┐ 2│ メール変更申請一覧:改修の目的 │ 3├───────────────────────────────┤ 4│ ① 一覧に存在する状態のみをフィルタ候補にする │ 5│ ② フィルタ初期状態を「全選択」扱いにして直感的に操作可能に │ 6│ ③ 状態ラベルを UI / CSV / フィルタ間で一元化 │ 7│ ④ Popover フィルタの検索ボックスを正しく機能させる │ 8│ ⑤ TableMeta 型で状態関連のメタデータを型安全に扱う │ 9└───────────────────────────────┘
これらを実現するために、以下の4層構造で改修を行いました。
ファイル主な修正内容
1. データ取得層page.tsx一覧データから動的に statusOptions を生成
2. 状態定義層status-multi-select.tsxStatusMultiSelect を RolesChecklist 準拠に刷新
3. 表示制御層data-table.tsx / columns.tsx状態フィルタの反映・ボタン活性化ロジックを修正
4. 型安全層table-meta.d.tsメール変更申請専用メタを拡張(statusOptions など)
この章では、上記の4層に沿って、ソースコードと構造を順に解説していきます。

page.tsx の更新 ─ 状態選択肢を動的生成

このステップでは、一覧のデータ取得と同時に 「登場した状態だけを選択肢として生成」 するように修正しました。
従来は StatusMultiSelect 内で固定的に "PENDING", "APPROVED" などを定義していましたが、
実際のデータに存在しないステータスまでフィルタ候補に出てしまうというUX上の問題がありました。
今回の改修では、page.tsx 側で Prisma の検索結果から status の集合を生成し、
それを statusOptions として DataTable に渡すように変更しています。
txt
1┌─────────────────────────────────────┐ 2│ 改修ポイント概要 │ 3├─────────────────────────────────────┤ 4│ ① Prisma で取得した行(rows)から │ 5│ status のユニーク値を抽出 │ 6│ ② STATUS_LABEL と突き合わせて │ 7│ value / label のペア配列を生成 │ 8│ ③ DataTable に props として渡す │ 9└─────────────────────────────────────┘
これにより、テーブル内に存在しない状態(たとえば EXPIRED が0件など)は
フィルタ選択肢に表示されなくなり、実データとUIの整合性が保たれるようになりました。
また、後続の StatusMultiSelect コンポーネントではこの statusOptions
RolesChecklist と同じ構造({ value, label }[])として受け取る前提に統一しています。
tsx
1// src/app/(protected)/users/email-change-requests/page.tsx 2import type { Metadata } from "next"; 3import { prisma } from "@/lib/database"; 4import { SidebarTrigger } from "@/components/ui/sidebar"; 5import { 6 Breadcrumb, 7 BreadcrumbItem, 8 BreadcrumbLink, 9 BreadcrumbList, 10 BreadcrumbPage, 11 BreadcrumbSeparator, 12} from "@/components/ui/breadcrumb"; 13import { Separator } from "@/components/ui/separator"; 14import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 15import * as punycode from "punycode/"; 16import { getEffectiveRole } from "@/lib/auth/effective-role"; 17import type { EmailChangeRow } from "./data-table"; // 後述の型に合わせる 18 19import DataTable from "./data-table"; 20import { columns } from "./columns"; 21import { STATUS_LABEL, type ReqStatus } from "./status-multi-select"; 22 23export const metadata: Metadata = { 24 title: "メールアドレス変更申請 | 管理画面レイアウト【DELOGs】", 25 description: "本人確認済み(VERIFIED)のメール変更申請を承認/却下します。", 26}; 27 28export default async function Page() { 29 // ← 返り値(viewer)を受ける 30 const viewer = await guardHrefOrRedirect("/users/email-change-requests", "/"); 31 // 1) 自分の部署特定 32 const me = await prisma.user.findUnique({ 33 where: { 34 id: /* guard 内の viewer.userId を使う */ ( 35 await guardHrefOrRedirect("/users/email-change-requests", "/") 36 ).userId, 37 }, 38 select: { departmentId: true }, 39 }); 40 if (!me) return null; 41 42 // 2) 申請データ取得(部署内・未削除ユーザに紐づく申請) 43 const raw = await prisma.emailChangeRequest.findMany({ 44 where: { departmentId: me.departmentId }, 45 orderBy: { createdAt: "desc" }, 46 select: { 47 id: true, 48 createdAt: true, // = requestedAt 相当 49 processedAt: true, 50 processedBy: true, 51 status: true, 52 oldEmailPuny: true, 53 newEmailPuny: true, 54 user: { 55 select: { 56 displayId: true, 57 name: true, 58 roleId: true, 59 departmentRoleId: true, 60 }, 61 }, 62 }, 63 }); 64 65 // 3) 実効ロールを付与 66 const rows: EmailChangeRow[] = await Promise.all( 67 raw.map(async (r) => { 68 const eff = r.user.departmentRoleId 69 ? await getEffectiveRole({ 70 departmentId: me.departmentId, 71 departmentRoleId: r.user.departmentRoleId, 72 }) 73 : r.user.roleId 74 ? await getEffectiveRole({ 75 departmentId: me.departmentId, 76 roleId: r.user.roleId, 77 }) 78 : null; 79 80 return { 81 id: r.id, 82 requestedAt: r.createdAt, 83 processedAt: r.processedAt ?? null, 84 processedBy: r.processedBy ?? null, 85 accountId: r.user.displayId, // 表示IDを「アカウントID」列に流用 or 別途取得 86 userName: r.user.name, 87 oldEmail: punycode.toUnicode(r.oldEmailPuny), 88 newEmail: punycode.toUnicode(r.newEmailPuny), 89 status: r.status, 90 roleCode: eff?.code ?? "", 91 roleName: eff?.name ?? "(不明)", 92 roleBadgeColor: eff?.badgeColor ?? null, 93 }; 94 }), 95 ); 96 97 // 4) ロール選択肢(登場ロールのみ) 98 const roleOptions = Array.from( 99 new Map( 100 rows.map((r) => [r.roleCode, { value: r.roleCode, label: r.roleName }]), 101 ).values(), 102 ); 103 104 // 5) 状態選択肢(登場状態のみ) 105 const statusOptions = Array.from(new Set(rows.map((r) => r.status))).map( 106 (s) => ({ value: s as ReqStatus, label: STATUS_LABEL[s as ReqStatus] }), 107 ); 108 109 return ( 110 <> 111 <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"> 112 <div className="flex items-center gap-2 px-4"> 113 <SidebarTrigger className="-ml-1" /> 114 <Separator 115 orientation="vertical" 116 className="mr-2 data-[orientation=vertical]:h-4" 117 /> 118 <Breadcrumb> 119 <BreadcrumbList> 120 <BreadcrumbItem className="hidden md:block"> 121 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 122 </BreadcrumbItem> 123 <BreadcrumbSeparator className="hidden md:block" /> 124 <BreadcrumbItem> 125 <BreadcrumbPage>メールアドレス変更申請</BreadcrumbPage> 126 </BreadcrumbItem> 127 </BreadcrumbList> 128 </Breadcrumb> 129 </div> 130 </header> 131 132 <div className="container p-4 pt-0"> 133 {/* canDownloadData を渡す */} 134 <DataTable 135 columns={columns} 136 roleOptions={roleOptions} 137 statusOptions={statusOptions} 138 data={rows} 139 canDownloadData={viewer.canDownloadData} 140 /> 141 </div> 142 </> 143 ); 144}
このように、page.tsx は一覧全体の「選択肢生成の起点」となり、
状態フィルタとロールフィルタの双方で共通の設計を採用できるようになりました。
次の節では、この statusOptions を実際に受け取る StatusMultiSelect の改修内容を解説します。

StatusMultiSelect コンポーネントの改修

この節では、状態フィルタの中心となる StatusMultiSelect コンポーネントの改修について解説します。
従来の実装は 固定配列 ALL_STATUSES に依存しており、一覧に存在しない状態まで選択肢として表示されていました。
また、検索ボックスが動作しない・初期選択状態が直感的でないといった問題もありました。
今回の改修では、RolesChecklist の構造をベースに書き直し、
データ駆動型の選択肢生成・検索・全選択制御を実現しています。
txt
1┌────────────────────────────────────┐ 2│ 改修ポイント概要 │ 3├────────────────────────────────────┤ 4│ ① props で渡される `options` を │ 5│ 一覧データに合わせて動的生成 │ 6│ ② "PENDING" / {value, label} どちらも│ 7│ 受け取れるように正規化処理を追加 │ 8│ ③ 検索ワード(needle)でコード・ラベル│ 9│ の両方をフィルタ対象に │ 10│ ④ 全選択時は空配列 `[]` に畳む │ 11│ (URLクエリ短縮 & 一貫性確保) │ 12│ ⑤ `footer` スロットで「閉じる」ボタン│ 13│ 等を外部から挿入可能に │ 14└────────────────────────────────────┘
特に重要なのは「optionsの正規化」と「全選択時の空配列化」です。
これにより、他のフィルタ(ロールや日付レンジ)と同様の扱いが可能になり、
DataTable との同期処理が簡潔に保たれます。
ts
1// src/app/(protected)/users/email-change-requests/status-multi-select.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 type ReqStatus = 17 | "PENDING" 18 | "VERIFIED" 19 | "APPROVED" 20 | "REJECTED" 21 | "EXPIRED"; 22 23export const STATUS_LABEL: Record<ReqStatus, string> = { 24 PENDING: "未認証", 25 VERIFIED: "本人確認済", 26 APPROVED: "承認済", 27 REJECTED: "却下", 28 EXPIRED: "期限切れ", 29}; 30 31export const ALL_STATUSES: ReqStatus[] = [ 32 "PENDING", 33 "VERIFIED", 34 "APPROVED", 35 "REJECTED", 36 "EXPIRED", 37]; 38 39export function StatusMultiSelect({ 40 value, 41 onChange, 42 options, 43 footer, 44}: { 45 /** 選択中。空配列は「すべて」を意味する(URL/状態同期の表現) */ 46 value: ReqStatus[]; 47 onChange: (next: ReqStatus[]) => void; 48 /** 一覧に“登場した”状態のみ(value/label) */ 49 options: Array<ReqStatus | { value: ReqStatus; label: string }>; 50 footer?: React.ReactNode; 51}) { 52 // 検索ワード 53 const [needle, setNeedle] = React.useState(""); 54 55 // ★ options の正規化("PENDING" | {value:"PENDING",label:"未認証"} どちらでもOKに) 56 const normalized = React.useMemo( 57 () => 58 options.map((o) => 59 typeof o === "string" 60 ? { value: o, label: STATUS_LABEL[o] } 61 : { value: o.value, label: o.label ?? STATUS_LABEL[o.value] }, 62 ), 63 [options], 64 ); 65 // 見かけ上の選択集合(空=すべて → 全要素を選択表示) 66 const all = React.useMemo(() => normalized.map((o) => o.value), [normalized]); 67 const effectiveSelected = React.useMemo<ReqStatus[]>( 68 () => (value.length ? value : all), 69 [value, all], 70 ); 71 72 const toggle = (s: ReqStatus) => { 73 const set = new Set(effectiveSelected); 74 if (set.has(s)) { 75 set.delete(s); 76 } else { 77 set.add(s); 78 } 79 const next = Array.from(set) as ReqStatus[]; 80 onChange(next.length === all.length ? [] : next); // 全選択→ [] に畳む 81 }; 82 83 const allSelected = effectiveSelected.length === all.length; 84 85 // フィルタリング(コード/ラベルの両方を対象に) 86 const filtered = React.useMemo(() => { 87 const q = needle.trim().toLowerCase(); 88 if (!q) return normalized; 89 return normalized.filter( 90 (o) => 91 o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q), 92 ); 93 }, [needle, normalized]); 94 95 return ( 96 <div className="flex max-h-[60vh] w-full flex-col"> 97 <div className="p-2"> 98 <Command shouldFilter={false}> 99 <CommandInput 100 value={needle} 101 onValueChange={setNeedle} 102 placeholder="状態を検索…" 103 /> 104 <CommandEmpty>該当する状態がありません</CommandEmpty> 105 <CommandGroup heading="状態を選択(複数選択可)"> 106 {filtered.map((o) => { 107 const s = o.value; 108 const checked = effectiveSelected.includes(s); 109 return ( 110 <CommandItem 111 key={s} 112 onSelect={() => toggle(s)} 113 className="flex items-center gap-2" 114 > 115 <span 116 className="flex h-5 w-5 items-center justify-center rounded border" 117 aria-checked={checked} 118 role="checkbox" 119 > 120 {checked ? <Check className="h-3 w-3" /> : null} 121 </span> 122 <span className="truncate">{o.label}</span> 123 </CommandItem> 124 ); 125 })} 126 </CommandGroup> 127 </Command> 128 </div> 129 130 <Separator /> 131 132 <div className="flex items-center justify-between gap-2 p-2"> 133 <div className="flex gap-2"> 134 <Button 135 type="button" 136 variant="ghost" 137 size="sm" 138 // “すべて”は空配列にする(=既存の表現に統一) 139 onClick={() => onChange([])} 140 disabled={allSelected} 141 className="cursor-pointer" 142 > 143 すべて 144 </Button> 145 <Button 146 type="button" 147 variant="ghost" 148 size="sm" 149 onClick={() => onChange([])} // 実質“すべて”と同義にして運用簡略化 150 className="cursor-pointer" 151 > 152 クリア 153 </Button> 154 </div> 155 <div className="flex items-center">{footer}</div> 156 </div> 157 </div> 158 ); 159}
この新構成により、状態フィルタは次のように動作します。
操作動作内容
初期状態一覧に存在する全ての状態が選択扱い(=すべて)
状態をクリック該当項目のみ選択/解除
検索欄に入力コード・ラベル両方から一致候補を絞り込み
「すべて」ボタン全状態を選択(空配列に変換)
「クリア」ボタン全選択を解除(空配列として保持)
次の節では、この StatusMultiSelect を利用して実際に状態フィルタを反映させる
DataTable 側の修正点を見ていきます。

DataTable の修正 ─ 状態フィルタとCSV出力の統合

この節では、DataTable に「状態」フィルタを統合し、CSV 出力でも日本語ラベルが出るように整えたポイントをまとめます。実装は URL 同期ベース(useDatagridQueryState)で、ユーザ一覧と同じ操作感に統一しています。

変更概要(要点)

  • 状態フィルタのデータ駆動化
    page.tsx で作った statusOptions(実データに登場した状態のみ)を DataTable に渡し、ここから 全状態集合(allStatusCodes) を生成。クエリ側の statuses が空配列なら「すべて」を意味し、フィルタには allStatusCodes を使います。
  • ローカル絞り込みに一本化
    行データ(localRows)を取り込み後、検索語・ロール・状態・日付レンジの すべてをクライアント側で合成フィルタ
    状態は statuses.length ? statuses : allStatusCodes という解釈で UX を単純化。
  • 列ヘッダのフィルタ UI と同期
    columns.tsx から table.options.meta を介して statusOptions / statuses / setStatuses を受け、ヘッダのポップオーバーから 直接 StatusMultiSelect が開く(ロールと同様の挙動)。
  • サマリ表示の日本語化
    DatagridSummary に渡す statusTextstatuses.map(statusToLabel) に変更し、"PENDING" → "未認証" のように日本語で表示。
  • CSV の日本語ラベル化
    エクスポート時に status 列は statusToLabel(r.status) を使い、CSV でも日本語が出力されるように統一。

Before / After(ふるまいの差分)

項目修正前修正後
状態の選択肢固定集合(存在しない状態も表示)一覧に登場した状態のみを表示
初期選択明確でない/要クリック空配列=すべて扱い(実質全選択)
絞り込み処理サーバ&クライアントの折衷クライアント一括(検索・ロール・状態・日付を合成)
サマリ表示英字コード表示日本語ラベル表示
CSV 出力英字コード出力日本語ラベル出力

実装のポイント

  • allStatusCodesstatusOptions からコード配列を作成(=一覧に登場した状態の全集合)
  • statusesForFilterqueryState.statuses.length ? queryState.statuses : allStatusCodes
  • フィルタ本体 … statusSet.has(r.status)(ほか、ロール・日付・キーワードと合成)
  • サマリ … queryState.statuses.map(statusToLabel).join(", ")(空なら「状態: すべて」)
  • CSV … statusToLabel(r.status) を使用して日本語化
tsx
1// src/app/(protected)/users/email-change-requests/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 getCoreRowModel, 12 getPaginationRowModel, 13 getSortedRowModel, 14 useReactTable, 15 flexRender, 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 { toast } from "sonner"; 26import type { DateRange } from "react-day-picker"; 27import { format } from "date-fns"; 28import { ja } from "date-fns/locale"; 29import { DatagridToolbar } from "@/components/datagrid/datagrid-toolbar"; 30import { DatagridSummary } from "@/components/datagrid/datagrid-summary"; 31import { DatagridPagination } from "@/components/datagrid/datagrid-pagination"; 32import { useDatagridQueryState } from "@/lib/datagrid/use-datagrid-query-state"; 33import { usePersistentDatagridState } from "@/lib/datagrid/use-persistent-datagrid-state"; 34import { fromDateRange, toDateRange } from "@/lib/datagrid/date-io"; 35import { buildCsv, downloadCsv, fmtDateTime } from "@/lib/datagrid/csv"; 36import type { RoleOption } from "@/components/filters/roles-checklist"; 37import { 38 approveEmailChangeRequestAction, 39 rejectEmailChangeRequestAction, 40} from "@/app/_actions/users/email-change-requests"; 41import { STATUS_LABEL, type ReqStatus } from "./status-multi-select"; 42 43export type EmailChangeRow = { 44 id: string; 45 requestedAt: Date; 46 verifiedAt?: Date | null; 47 processedAt?: Date | null; 48 processedBy?: string | null; 49 accountId: string; 50 userName: string; 51 oldEmail: string; // ASCII 52 newEmail: string; // ASCII 53 status: ReqStatus; 54 // ★ 追加:ロール表示 55 roleCode: string; 56 roleName: string; 57 roleBadgeColor: string | null; 58}; 59 60type Props = { 61 columns: ColumnDef<EmailChangeRow, unknown>[]; 62 data: EmailChangeRow[]; 63 roleOptions: RoleOption[]; 64 canDownloadData?: boolean; 65 statusOptions: { value: ReqStatus; label: string }[]; 66}; 67 68const statusToLabel = (s: ReqStatus) => STATUS_LABEL[s] ?? s; 69 70export default function DataTable({ 71 columns, 72 data, 73 roleOptions, 74 canDownloadData = false, 75 statusOptions, 76}: Props) { 77 const [mounted, setMounted] = React.useState(false); 78 React.useEffect(() => setMounted(true), []); 79 80 // 列ID(actions / q 除外) 81 const allColumnIds = React.useMemo( 82 () => 83 [ 84 "requestedAt", 85 "accountId", 86 "userName", 87 "roleCode", 88 "oldEmail", 89 "newEmail", 90 "status", 91 "processedAt", 92 "processedBy", 93 ] as const, 94 [], 95 ); 96 type ColId = (typeof allColumnIds)[number]; 97 98 // URL同期(メール申請一覧用のネームスペース) 99 const [queryState, setQueryState] = useDatagridQueryState( 100 "email-reqs", 101 { 102 q: "", 103 roles: [] as string[], // 空=すべて 104 statuses: [] as ReqStatus[], // 空=すべて 105 requestedRange: undefined as { from?: string; to?: string } | undefined, 106 processedRange: undefined as { from?: string; to?: string } | undefined, 107 cols: Array.from(allColumnIds) as ColId[], 108 }, 109 { persistKey: "email-reqs" }, 110 ); 111 112 // ページサイズは localStorage 113 const [persisted, setPersisted] = usePersistentDatagridState("email-reqs", { 114 pageSize: 20, 115 }); 116 117 // 並び順 118 const [sorting, setSorting] = React.useState<SortingState>([ 119 { id: "requestedAt", desc: true }, 120 ]); 121 122 // 役割(空配列=すべて) 123 const allRoleCodes = React.useMemo( 124 () => roleOptions.map((o) => o.value), 125 [roleOptions], 126 ); 127 const rolesForFilter = queryState.roles.length 128 ? queryState.roles 129 : allRoleCodes; 130 131 // 状態(空配列=すべて) 132 const allStatusCodes = React.useMemo( 133 () => statusOptions.map((o) => o.value), 134 [statusOptions], 135 ); 136 const statusesForFilter = queryState.statuses.length 137 ? queryState.statuses 138 : allStatusCodes; 139 140 // 日付レンジ 141 const requestedRange = React.useMemo( 142 () => toDateRange(queryState.requestedRange), 143 [queryState.requestedRange], 144 ); 145 const processedRange = React.useMemo( 146 () => toDateRange(queryState.processedRange), 147 [queryState.processedRange], 148 ); 149 150 // setter 群 151 const setQ = (v: string) => setQueryState((s) => ({ ...s, q: v })); 152 const setRoles = (next: string[]) => 153 setQueryState((s) => ({ ...s, roles: next })); 154 const setStatuses = (next: ReqStatus[]) => 155 setQueryState((s) => ({ ...s, statuses: next })); 156 const setRequestedRange = (r?: DateRange) => 157 setQueryState((s) => ({ ...s, requestedRange: fromDateRange(r) })); 158 const setProcessedRange = (r?: DateRange) => 159 setQueryState((s) => ({ ...s, processedRange: fromDateRange(r) })); 160 const setVisibleColumnIds = (ids: ColId[]) => 161 setQueryState((s) => ({ ...s, cols: ids })); 162 163 // ローカル行(承認/却下後に更新) 164 const [localRows, setLocalRows] = React.useState<EmailChangeRow[]>( 165 () => data, 166 ); 167 React.useEffect(() => setLocalRows(data), [data]); 168 169 // 承認 170 const onApprove = React.useCallback( 171 async (id: string) => { 172 const fd = new FormData(); 173 fd.set("id", id); 174 const res = await approveEmailChangeRequestAction(fd); 175 if (!res.ok) { 176 toast.error(res.message ?? "承認に失敗しました"); 177 return; 178 } 179 // ローカル即時反映 180 setLocalRows((prev) => 181 prev.map((r) => 182 r.id === id 183 ? { 184 ...r, 185 status: "APPROVED", 186 processedAt: new Date(), 187 processedBy: "(you)", 188 } 189 : r, 190 ), 191 ); 192 toast.success("申請を承認しました"); 193 }, 194 [setLocalRows], 195 ); 196 197 // 却下 198 const onReject = React.useCallback( 199 async (id: string) => { 200 const fd = new FormData(); 201 fd.set("id", id); 202 const res = await rejectEmailChangeRequestAction(fd); 203 if (!res.ok) { 204 toast.error(res.message ?? "却下に失敗しました"); 205 return; 206 } 207 setLocalRows((prev) => 208 prev.map((r) => 209 r.id === id 210 ? { 211 ...r, 212 status: "REJECTED", 213 processedAt: new Date(), 214 processedBy: "(you)", 215 } 216 : r, 217 ), 218 ); 219 toast.message("申請を却下しました"); 220 }, 221 [setLocalRows], 222 ); 223 // 列可視(初回SSR一致のためマウント後に反映) 224 const effectiveVisibleColumnIds: ColId[] = mounted 225 ? (queryState.cols as ColId[]) 226 : (Array.from(allColumnIds) as ColId[]); 227 const columnVisibility = React.useMemo<VisibilityState>(() => { 228 const set = new Set(effectiveVisibleColumnIds); 229 return Object.fromEntries( 230 (["actions", "q", ...allColumnIds] as const).map((id) => [ 231 id, 232 id === "actions" ? true : set.has(id as ColId), 233 ]), 234 ) as VisibilityState; 235 }, [effectiveVisibleColumnIds, allColumnIds]); 236 // フィルタ(ローカル) 237 const filteredData = React.useMemo(() => { 238 const needle = queryState.q.trim().toLowerCase(); 239 const roleSet = new Set(rolesForFilter); 240 const statusSet = new Set(statusesForFilter); 241 const inRange = (d?: Date | null, r?: DateRange) => { 242 if (!d) return false; 243 if (!r?.from && !r?.to) return true; 244 const ts = d.getTime(); 245 if (r?.from && ts < new Date(r.from).setHours(0, 0, 0, 0)) return false; 246 if (r?.to && ts > new Date(r.to).setHours(23, 59, 59, 999)) return false; 247 return true; 248 }; 249 return localRows.filter((r) => { 250 const passQ = 251 !needle || 252 `${r.accountId} ${r.userName} ${r.oldEmail} ${r.newEmail}` 253 .toLowerCase() 254 .includes(needle); 255 const passRole = roleSet.has(r.roleCode); 256 const passStatus = statusSet.has(r.status); 257 const passRequested = inRange(r.requestedAt, requestedRange); 258 const passProcessed = r.processedAt 259 ? inRange(r.processedAt, processedRange) 260 : !processedRange?.from && !processedRange?.to; 261 return passQ && passRole && passStatus && passRequested && passProcessed; 262 }); 263 }, [ 264 localRows, 265 queryState.q, 266 rolesForFilter, 267 statusesForFilter, 268 requestedRange, 269 processedRange, 270 ]); 271 272 // テーブル 273 const table = useReactTable({ 274 data: filteredData, 275 columns, 276 state: { sorting, columnVisibility }, 277 onSortingChange: setSorting, 278 getCoreRowModel: getCoreRowModel(), 279 getSortedRowModel: getSortedRowModel(), 280 getPaginationRowModel: getPaginationRowModel(), 281 initialState: { pagination: { pageIndex: 0, pageSize: 20 } }, 282 meta: { 283 onApprove, 284 onReject, 285 // フィルタUIが読むメタ 286 roleOptions, 287 roles: rolesForFilter, 288 setRoles, 289 statusOptions, 290 statuses: queryState.statuses, 291 setStatuses, 292 requestedRange, 293 setRequestedRange, 294 processedRange, 295 setProcessedRange, 296 }, 297 }); 298 299 React.useEffect(() => { 300 if (mounted) table.setPageSize(persisted.pageSize); 301 }, [mounted, persisted.pageSize, table]); 302 303 // CSV(可視列のみ) 304 const columnLabels = React.useMemo( 305 () => 306 ({ 307 requestedAt: "申請日時", 308 accountId: "アカウントID", 309 userName: "ユーザ名", 310 roleCode: "ロール", 311 oldEmail: "旧メール", 312 newEmail: "新メール", 313 status: "状態", 314 processedAt: "処理日時", 315 processedBy: "処理者", 316 }) as const, 317 [], 318 ); 319 const onDownloadCsv = React.useCallback(() => { 320 const visibleLeaf = table 321 .getVisibleLeafColumns() 322 .map((c) => c.id) 323 .filter((id) => id !== "actions" && id !== "q") as ColId[]; 324 const headers = visibleLeaf.map((id) => columnLabels[id]); 325 const rows = filteredData.map((r) => 326 visibleLeaf.map((id) => { 327 switch (id) { 328 case "requestedAt": 329 return fmtDateTime(r.requestedAt); 330 case "accountId": 331 return r.accountId; 332 case "userName": 333 return r.userName; 334 case "roleCode": 335 return r.roleName || r.roleCode; 336 case "oldEmail": 337 return r.oldEmail; 338 case "newEmail": 339 return r.newEmail; 340 case "status": 341 return statusToLabel(r.status); 342 case "processedAt": 343 return r.processedAt ? fmtDateTime(r.processedAt) : ""; 344 case "processedBy": 345 return r.processedBy ?? ""; 346 default: 347 return ""; 348 } 349 }), 350 ); 351 const csv = buildCsv(headers, rows); 352 const ts = format(new Date(), "yyyyMMdd_HHmmss", { locale: ja }); 353 downloadCsv(`email_change_requests_${ts}.csv`, csv); 354 }, [filteredData, table, columnLabels]); 355 356 const filteredCount = filteredData.length; 357 358 // 画面:ユーザ一覧の薄型版に揃える 359 return ( 360 <div className="space-y-3"> 361 <DatagridToolbar<ColId> 362 q={queryState.q} 363 onChangeQ={setQ} 364 columnOptions={allColumnIds.map((id) => ({ 365 value: id, 366 label: columnLabels[id], 367 }))} 368 visibleColumnIds={effectiveVisibleColumnIds} 369 onChangeVisibleColumns={setVisibleColumnIds} 370 canDownloadData={canDownloadData} 371 onDownloadCsv={onDownloadCsv} 372 /> 373 374 <div className="flex items-center justify-between gap-3"> 375 <div className="text-sm" data-testid="count"> 376 表示件数: {filteredCount}377 </div> 378 {/* サマリ + 全フィルタ解除 */} 379 <div className="flex max-w-[60%] items-center justify-end gap-2"> 380 <DatagridSummary 381 mounted={mounted} 382 roleText={ 383 queryState.roles.length === 0 384 ? "ロール: すべて" 385 : `ロール: ${queryState.roles 386 .map( 387 (v) => 388 new Map(roleOptions.map((o) => [o.value, o.label])).get( 389 v, 390 ) ?? v, 391 ) 392 .join(", ")}` 393 } 394 statusText={ 395 queryState.statuses.length === 0 396 ? "状態: すべて" 397 : `状態: ${queryState.statuses.map(statusToLabel).join(", ")}` 398 } 399 createdRangeISO={queryState.requestedRange} 400 updatedRangeISO={queryState.processedRange} 401 createdRange={requestedRange} 402 updatedRange={processedRange} 403 visibleColsText={effectiveVisibleColumnIds 404 .map((id) => columnLabels[id]) 405 .join(", ")} 406 /> 407 <button 408 type="button" 409 className="text-muted-foreground shrink-0 cursor-pointer text-xs underline" 410 title="全フィルタ解除" 411 onClick={() => { 412 setQueryState((s) => ({ 413 ...s, 414 q: "", 415 roles: [], 416 statuses: [], 417 requestedRange: undefined, 418 processedRange: undefined, 419 cols: Array.from(allColumnIds) as ColId[], 420 })); 421 setPersisted((p) => ({ ...p, pageSize: 20 })); 422 table.setPageSize(20); 423 }} 424 > 425 全フィルタ解除 426 </button> 427 </div> 428 </div> 429 430 <div className="overflow-x-auto rounded-md border pb-1"> 431 <Table className="w-full" data-testid="email-change-table"> 432 <TableHeader className="bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur"> 433 {table.getHeaderGroups().map((hg) => ( 434 <TableRow key={hg.id}> 435 {hg.headers.map((header) => ( 436 <TableHead 437 key={header.id} 438 style={{ width: header.column.getSize() }} 439 > 440 {header.isPlaceholder 441 ? null 442 : flexRender( 443 header.column.columnDef.header, 444 header.getContext(), 445 )} 446 </TableHead> 447 ))} 448 </TableRow> 449 ))} 450 </TableHeader> 451 <TableBody> 452 {table.getRowModel().rows.length ? ( 453 table.getRowModel().rows.map((row) => ( 454 <TableRow key={row.id} data-testid={`row-${row.original.id}`}> 455 {row.getVisibleCells().map((cell) => ( 456 <TableCell 457 key={cell.id} 458 style={{ width: cell.column.getSize() }} 459 > 460 {flexRender( 461 cell.column.columnDef.cell, 462 cell.getContext(), 463 )} 464 </TableCell> 465 ))} 466 </TableRow> 467 )) 468 ) : ( 469 <TableRow> 470 <TableCell 471 colSpan={table.getAllColumns().length} 472 className="text-muted-foreground py-10 text-center text-sm" 473 > 474 条件に一致する申請が見つかりませんでした。 475 </TableCell> 476 </TableRow> 477 )} 478 </TableBody> 479 </Table> 480 </div> 481 482 <DatagridPagination<EmailChangeRow> 483 table={table} 484 pageSize={table.getState().pagination.pageSize} 485 onChangePageSize={(n) => { 486 table.setPageSize(n); 487 setPersisted((p) => ({ ...p, pageSize: n })); 488 }} 489 /> 490 </div> 491 ); 492}

運用メモ

  • 「全フィルタ解除」ボタンは statuses を空配列に戻すため、再び「すべて」扱い に戻ります。
  • ページサイズは usePersistentDatagridState で維持し、初回レンダリング差を避けるために mounted フラグ で反映タイミングを制御しています。
  • columns.tsx の「状態」ヘッダは statusOptions の長さと選択数の比較で ボタンの活性/不活性スタイル を判定しています(ロールと同じロジック)。

columns.tsx の更新 ─ 状態フィルタUIの再構成

この節では、テーブル列定義 columns.tsx の更新内容を解説します。
ここでの主な目的は、状態フィルタのUIをロールフィルタと統一的な構成に再整理すること です。
従来の列定義は、ヘッダタイトルを文字列で直接指定するだけのシンプルな構造でしたが、
今回の改修では、状態・ロール・日付といった複合的な絞り込み操作をすべて
同じUIフレーム(Popover + Command)で扱うようにしました。
txt
1┌──────────────────────────────────────────┐ 2│ 改修後のヘッダ構造 │ 3├──────────────────────────────────────────┤ 4│ HeaderWithSort … ソート専用 │ 5│ HeaderWithFilter … フィルタ兼ソート用 │ 6│ ├─ PopoverTrigger(SlidersVertical) │ 7│ ├─ PopoverContent(StatusMultiSelect等) │ 8│ └─ SortButton(任意) │ 9└──────────────────────────────────────────┘
状態列 (status) の部分では、StatusMultiSelectHeaderWithFilter 内に組み込み、
table.options.meta に登録された statusOptions / statuses / setStatuses を参照するようにしました。
そのため、ヘッダポップオーバーから直接選択状態を変更でき、
他のフィルタ(ロール・日付レンジ)とまったく同じ操作感を実現しています。
tsx
1// src/app/(protected)/users/email-change-requests/columns.tsx 2"use client"; 3 4import type { ColumnDef, HeaderContext } from "@tanstack/react-table"; 5import { format } from "date-fns"; 6import { ja } from "date-fns/locale"; 7import { Badge } from "@/components/ui/badge"; 8import { Button } from "@/components/ui/button"; 9import * as punycode from "punycode/"; 10import type { EmailChangeRow } from "./data-table"; 11import { SlidersVertical } from "lucide-react"; 12import { 13 Popover, 14 PopoverTrigger, 15 PopoverContent, 16} from "@/components/ui/popover"; 17import * as PopoverPrimitive from "@radix-ui/react-popover"; 18import { DateRangePicker } from "@/components/filters/date-range-picker"; 19import { SortButton } from "@/components/datagrid/sort-button"; 20import { StatusMultiSelect, type ReqStatus } from "./status-multi-select"; 21import { RolesChecklist } from "@/components/filters/roles-checklist"; 22 23function fmt(d?: Date | null) { 24 if (!d) return "-"; 25 return format(d, "yyyy/MM/dd HH:mm", { locale: ja }); 26} 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 HeaderWithFilter({ 48 title, 49 active, 50 children, 51 contentClassName, 52 trailing, 53}: { 54 title: string; 55 active: boolean; 56 children: React.ReactNode; 57 contentClassName?: string; 58 trailing?: React.ReactNode; 59}) { 60 return ( 61 <div className="flex items-center gap-1"> 62 <span className="whitespace-nowrap">{title}</span> 63 <Popover> 64 <PopoverTrigger asChild> 65 <Button 66 type="button" 67 size="icon" 68 variant={active ? "default" : "outline"} 69 className="h-7 w-7 cursor-pointer" 70 aria-label={`${title}のフィルタ`} 71 title={`${title}のフィルタ`} 72 > 73 <SlidersVertical className="h-3.5 w-3.5" /> 74 </Button> 75 </PopoverTrigger> 76 <PopoverContent 77 align="end" 78 className={["p-0", contentClassName ?? "w-80"].join(" ")} 79 > 80 {children} 81 </PopoverContent> 82 </Popover> 83 {trailing ? <div className="ml-0.5">{trailing}</div> : null} 84 </div> 85 ); 86} 87 88export const columns: ColumnDef<EmailChangeRow>[] = [ 89 { 90 accessorKey: "requestedAt", 91 header: (ctx) => { 92 const table = ctx.table; 93 const r = table.options.meta?.requestedRange; 94 const setR: ( 95 r: import("react-day-picker").DateRange | undefined, 96 ) => void = table.options.meta?.setRequestedRange ?? (() => {}); 97 const active = !!(r?.from || r?.to); 98 return ( 99 <HeaderWithFilter 100 title="申請日時" 101 active={active} 102 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]" 103 trailing={ 104 <SortButton 105 column={ctx.column} 106 aria-label="申請日時でソート" 107 title="申請日時でソート" 108 /> 109 } 110 > 111 <DateRangePicker label="申請日時" value={r} onChange={setR} /> 112 </HeaderWithFilter> 113 ); 114 }, 115 cell: ({ row }) => fmt(row.original.requestedAt), 116 }, 117 { 118 accessorKey: "accountId", 119 header: (ctx) => <HeaderWithSort title="アカウントID" ctx={ctx} />, 120 }, 121 { 122 accessorKey: "userName", 123 header: (ctx) => <HeaderWithSort title="ユーザ名" ctx={ctx} />, 124 }, 125 // ★ 追加:申請者ロール(バッジ色つき) 126 { 127 accessorKey: "roleCode", 128 header: (ctx) => { 129 const table = ctx.table; 130 const roleOptions = table.options.meta?.roleOptions ?? []; 131 const roles = 132 table.options.meta?.roles ?? roleOptions.map((o) => o.value); 133 const setRoles = table.options.meta?.setRoles ?? (() => {}); 134 const active = roles.length !== roleOptions.length; 135 136 return ( 137 <HeaderWithFilter 138 title="ロール" 139 active={active} 140 contentClassName="w-[340px]" 141 trailing={ 142 <SortButton 143 column={ctx.column} 144 aria-label="ロールでソート" 145 title="ロールでソート" 146 /> 147 } 148 > 149 <RolesChecklist 150 value={roles} 151 onChange={setRoles} 152 options={roleOptions} 153 footer={ 154 <PopoverPrimitive.Close asChild> 155 <Button 156 variant="ghost" 157 size="sm" 158 type="button" 159 className="cursor-pointer" 160 > 161 閉じる 162 </Button> 163 </PopoverPrimitive.Close> 164 } 165 /> 166 </HeaderWithFilter> 167 ); 168 }, 169 size: 60, 170 cell: ({ row }) => { 171 const { roleCode, roleName, roleBadgeColor } = row.original; 172 const style = roleBadgeColor 173 ? { backgroundColor: roleBadgeColor, color: "#fff", border: "none" } 174 : undefined; 175 return ( 176 <Badge 177 variant={roleBadgeColor ? "secondary" : "default"} 178 style={style} 179 title={roleCode} 180 > 181 {roleName} 182 </Badge> 183 ); 184 }, 185 }, 186 { 187 accessorKey: "oldEmail", 188 header: (ctx) => <HeaderWithSort title="旧メール" ctx={ctx} />, 189 cell: ({ row }) => punycode.toUnicode(row.original.oldEmail), 190 }, 191 { 192 accessorKey: "newEmail", 193 header: (ctx) => <HeaderWithSort title="新メール" ctx={ctx} />, 194 cell: ({ row }) => ( 195 <span title={row.original.newEmail}> 196 {punycode.toUnicode(row.original.newEmail)} 197 </span> 198 ), 199 }, 200 { 201 accessorKey: "status", 202 header: (ctx) => { 203 const table = ctx.table; 204 const statusOptions = table.options.meta?.statusOptions ?? []; 205 const rawSelected = table.options.meta?.statuses ?? []; // URL/状態同期の生値([]=すべて) 206 const all = statusOptions.map((o) => o.value); 207 const statuses = rawSelected.length ? rawSelected : all; // UI上の見かけの選択 208 const setStatuses: (next: ReqStatus[]) => void = 209 table.options.meta?.setStatuses ?? (() => {}); 210 const active = (rawSelected?.length ?? 0) > 0; 211 return ( 212 <HeaderWithFilter title="状態" active={active}> 213 <StatusMultiSelect 214 options={statusOptions} 215 value={statuses} 216 onChange={setStatuses} 217 footer={ 218 <PopoverPrimitive.Close asChild> 219 <Button 220 variant="ghost" 221 size="sm" 222 type="button" 223 className="cursor-pointer" 224 > 225 閉じる 226 </Button> 227 </PopoverPrimitive.Close> 228 } 229 /> 230 </HeaderWithFilter> 231 ); 232 }, 233 cell: ({ row }) => { 234 const s = row.original.status; 235 if (s === "APPROVED") return <Badge>承認済</Badge>; 236 if (s === "REJECTED") return <Badge variant="destructive">却下</Badge>; 237 if (s === "VERIFIED") return <Badge variant="outline">本人確認済</Badge>; 238 if (s === "PENDING") return <Badge variant="outline">未認証</Badge>; 239 if (s === "EXPIRED") return <Badge variant="secondary">期限切れ</Badge>; 240 return <Badge variant="secondary">{s}</Badge>; 241 }, 242 }, 243 { 244 accessorKey: "processedAt", 245 header: (ctx) => { 246 const table = ctx.table; 247 const r = table.options.meta?.processedRange; 248 const setR: ( 249 r: import("react-day-picker").DateRange | undefined, 250 ) => void = table.options.meta?.setProcessedRange ?? (() => {}); 251 const active = !!(r?.from || r?.to); 252 return ( 253 <HeaderWithFilter 254 title="処理日時" 255 active={active} 256 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]" 257 trailing={ 258 <SortButton 259 column={ctx.column} 260 aria-label="処理日時でソート" 261 title="処理日時でソート" 262 /> 263 } 264 > 265 <DateRangePicker label="処理日時" value={r} onChange={setR} /> 266 </HeaderWithFilter> 267 ); 268 }, 269 cell: ({ row }) => fmt(row.original.processedAt), 270 }, 271 { 272 accessorKey: "processedBy", 273 header: (ctx) => <HeaderWithSort title="処理者" ctx={ctx} />, 274 }, 275 276 { 277 id: "actions", 278 header: "操作", 279 enableSorting: false, 280 enableResizing: false, 281 cell: ({ row, table }) => { 282 const r = row.original; 283 const canOperate = r.status === "VERIFIED"; 284 return ( 285 <div className="flex gap-2"> 286 <Button 287 size="sm" 288 disabled={!canOperate} 289 onClick={() => table.options.meta?.onApprove?.(r.id, r)} 290 data-testid={`approve-btn-${r.id}`} 291 className="cursor-pointer" 292 > 293 承認 294 </Button> 295 <Button 296 size="sm" 297 variant="outline" 298 disabled={!["PENDING", "VERIFIED"].includes(r.status)} 299 onClick={() => table.options.meta?.onReject?.(r.id, r)} 300 data-testid={`reject-btn-${r.id}`} 301 className="cursor-pointer" 302 > 303 却下 304 </Button> 305 </div> 306 ); 307 }, 308 }, 309 // hidden 検索列(q) 310 { 311 id: "q", 312 accessorFn: (r) => 313 `${r.accountId} ${r.userName} ${r.oldEmail} ${r.newEmail}`.toLowerCase(), 314 enableHiding: true, 315 enableSorting: false, 316 enableResizing: false, 317 size: 0, 318 header: () => null, 319 cell: () => null, 320 }, 321];
また、今回のリファクタリングでは以下のポイントを統一的に整理しています:
項目改修意図
HeaderWithFilter コンポーネント化Popover + SortButton の重複排除とUI統一
状態列の active 判定選択配列 rawSelected の長さで制御(空配列=全選択扱い)
ポップオーバーのフッター構造PopoverPrimitive.Close 経由で「閉じる」ボタンを柔軟に配置
ロール列との整合RolesChecklist と同一の呼び出し構成に変更
hidden列(q)追加テーブル検索用列を明示的に分離し、UI制御と検索ロジックを分離
これにより、列ヘッダのソート・フィルタ・検索がすべて同じ設計思想で動作するようになりました。
UI操作における一貫性が向上し、後続の一覧画面でも同様のヘッダ構成を簡単に再利用できるようになっています。

table-meta.d.ts の型拡張

この節では、テーブル列ヘッダから扱うフィルタ群(ロール/状態/日付レンジ)を 型で安全に受け渡し できるよう、@tanstack/table-coreTableMeta を拡張した点をまとめます。
ポイントは次の2つです。
  • 状態フィルタ専用の型ReqStatus)を導入し、statusOptions / statuses / setStatuses を型安全に。
  • 期間フィルタの分離(申請日時 requestedRange・処理日時 processedRange)で表現を明確化。
これにより、columns.tsx 側で table.options.meta を参照するだけで、各フィルタの現在値と setter を 型補完つき で扱えるようになりました。
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 /** 依頼を「再発行済み」にする */ 10 onIssue?: (id: string, _row?: TData) => void | Promise<void>; 11 /** 依頼を「拒否」にする */ 12 onReject?: (id: string, _row?: TData) => void | Promise<void>; 13 /** ★ 追加:メール変更申請を「承認」する */ 14 onApprove?: (id: string, _row?: TData) => void | Promise<void>; 15 // ▼ ユーザ一覧用(必要なものだけ) 16 roleOptions?: Array<{ value: string; label: string }>; 17 roles?: string[]; 18 setRoles?: (next: string[]) => void; 19 20 status?: "ALL" | "ACTIVE" | "INACTIVE"; 21 setStatus?: (next: "ALL" | "ACTIVE" | "INACTIVE") => void; 22 23 createdRange?: import("react-day-picker").DateRange | undefined; 24 setCreatedRange?: ( 25 r: import("react-day-picker").DateRange | undefined, 26 ) => void; 27 28 updatedRange?: import("react-day-picker").DateRange | undefined; 29 setUpdatedRange?: ( 30 r: import("react-day-picker").DateRange | undefined, 31 ) => void; 32 33 // ▼ ロール一覧用(masters/roles) 34 kindOptions?: Array<{ value: string; label: string }>; 35 kinds?: string[]; 36 setKinds?: (next: string[]) => void; 37 38 // ★ 追加:メール変更申請一覧用 39 // 状態(複数選択) 40 statusOptions?: Array<{ value: ReqStatus; label: string }>; 41 statuses?: ReqStatus[]; 42 setStatuses?: (next: ReqStatus[]) => void; 43 44 // 日付レンジ(申請日時・処理日時) 45 requestedRange?: import("react-day-picker").DateRange | undefined; 46 setRequestedRange?: ( 47 r: import("react-day-picker").DateRange | undefined, 48 ) => void; 49 50 processedRange?: import("react-day-picker").DateRange | undefined; 51 setProcessedRange?: ( 52 r: import("react-day-picker").DateRange | undefined, 53 ) => void; 54 } 55} 56 57export {};
拡張後の TableMeta で利用できる主なプロパティは次のとおりです。
分類プロパティ役割
操作onApprove / onReject(id: string, row?: TData) => void | Promise<void>申請の承認・却下ハンドラ
ロールroleOptions{ value: string; label: string }[]表示するロール選択肢
ロールroles / setRolesstring[] / (next: string[]) => void選択中ロールとその更新
状態statusOptions{ value: ReqStatus; label: string }[]一覧に登場した状態だけを選択肢として表示
状態statuses / setStatusesReqStatus[] / (next: ReqStatus[]) => void選択中状態(空配列=全選択)とその更新
日付requestedRange / setRequestedRangeDateRange | undefined / (r: DateRange | undefined) => void申請日時レンジの保持/更新
日付processedRange / setProcessedRangeDateRange | undefined / (r: DateRange | undefined) => void処理日時レンジの保持/更新
補足:
  • ReqStatusstatus-multi-select.tsx からインポートして再利用しています。型を一元化することで、CSV 出力DatagridSummary 表示 など状態名を扱う箇所も同じ型で統一でき、表記揺れや渡し間違いを防げます。
  • 「空配列=全選択」という既存のURL同期ルールに合わせるため、UI 側(StatusMultiSelect)は全選択状態を検出したら 空配列に畳む 実装にしています。TableMeta の型がその前提を明示しているため、呼び出し側の実装も迷いません。

サーバアクションの修正

承認/却下のサーバアクションは、実効ロール(priority)基準に統一し、同一部署ガードと競合対策を明示した構成に整理しました。 従来の role.code === "ADMIN" 判定は廃止し、getEffectiveRole による priority >= 100 を管理者しきい値とします。
さらに、対象申請の departmentId と操作者の部署一致を確認し、processedBy には実ユーザ名を保存するように変更しています。 なお、一覧取得は page.tsx のサーバ側で直読に切り替えたため、listEmailChangeRequestsAction は削除しました。
ts
1// src/app/_actions/users/email-change-requests.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { sendMail } from "@/lib/mailer"; 7import { emailChangeApprovedText } from "@/lib/email/templates"; 8import { z } from "zod"; 9import { getEffectiveRole } from "@/lib/auth/effective-role"; 10 11type ActionResult<T = unknown> = 12 | { ok: true; data?: T } 13 | { ok: false; message: string }; 14 15// ADMIN 相当のしきい値(update-user.ts と同一) 16const ADMIN_PRIORITY_THRESHOLD = 100; 17 18const idSchema = z.object({ id: z.uuid() }); 19 20/** 呼び出しユーザが ADMIN(実効 priority>=100)かつ同一部署かを確認 */ 21async function requireAdminSameDepartment( 22 userId: string, 23 departmentId: string, 24) { 25 const me = await prisma.user.findUnique({ 26 where: { id: userId }, 27 select: { 28 id: true, 29 name: true, 30 isActive: true, 31 departmentId: true, 32 roleId: true, 33 departmentRoleId: true, 34 }, 35 }); 36 if (!me || !me.isActive) 37 return { ok: false as const, message: "ユーザが無効化されています。" }; 38 39 if (me.departmentId !== departmentId) 40 return { ok: false as const, message: "越権操作です。" }; 41 42 // 実効ロールを取得 43 let eff = null; 44 if (me.departmentRoleId) { 45 eff = await getEffectiveRole({ 46 departmentId: me.departmentId, 47 departmentRoleId: me.departmentRoleId, 48 }); 49 } else if (me.roleId) { 50 eff = await getEffectiveRole({ 51 departmentId: me.departmentId, 52 roleId: me.roleId, 53 }); 54 } 55 if (!eff || eff.priority < ADMIN_PRIORITY_THRESHOLD) 56 return { ok: false as const, message: "権限がありません。" }; 57 58 // 呼び出し側で processedBy に使うので name も返す 59 return { ok: true as const, meName: me.name ?? "ADMIN" }; 60} 61 62export async function approveEmailChangeRequestAction( 63 formData: FormData, 64): Promise<ActionResult> { 65 // 1) 認証 66 const ses = await lookupSessionFromCookie(); 67 if (!ses.ok) return { ok: false, message: "認証が必要です" }; 68 69 // 2) 入力検証 70 const parsed = idSchema.safeParse({ id: formData.get("id") }); 71 if (!parsed.success) return { ok: false, message: "IDが不正です" }; 72 73 // 3) 対象取得 74 const req = await prisma.emailChangeRequest.findUnique({ 75 where: { id: parsed.data.id }, 76 select: { 77 id: true, 78 status: true, 79 userId: true, 80 departmentId: true, 81 oldEmailPuny: true, 82 newEmailPuny: true, 83 user: { select: { email: true } }, 84 }, 85 }); 86 if (!req) return { ok: false, message: "申請が見つかりません" }; 87 88 let operatorName; 89 // 4) ADMIN & 同部署チェック(実効ロール) 90 { 91 const g = await requireAdminSameDepartment(ses.userId, req.departmentId); 92 if (!g.ok) return g; 93 // 承認者名に利用 94 operatorName = g.meName; 95 } 96 97 if (req.status !== "VERIFIED") 98 return { ok: false, message: "本人確認済みの申請のみ承認できます" }; 99 100 // 5) 競合対策: 条件付き更新 + 反映 101 const result = await prisma.$transaction(async (tx) => { 102 const updated = await tx.emailChangeRequest.updateMany({ 103 where: { id: req.id, status: "VERIFIED" }, 104 data: { 105 status: "APPROVED", 106 processedAt: new Date(), 107 processedBy: operatorName, 108 }, 109 }); 110 if (updated.count !== 1) 111 return { ok: false as const, message: "競合が発生しました" }; 112 113 // ユーザのメールを更新(punycode ASCII で保存する設計) 114 await tx.user.update({ 115 where: { id: req.userId }, 116 data: { email: req.newEmailPuny }, 117 }); 118 119 // 任意: 他の保留申請を自動クローズ 120 await tx.emailChangeRequest.updateMany({ 121 where: { 122 userId: req.userId, 123 id: { not: req.id }, 124 status: { in: ["PENDING", "VERIFIED"] }, 125 }, 126 data: { 127 status: "REJECTED", 128 processedAt: new Date(), 129 processedBy: "system", 130 }, 131 }); 132 133 return { ok: true as const }; 134 }); 135 136 if (!result.ok) return result; 137 138 // 6) 承認通知メール(失敗しても処理は成功) 139 try { 140 await sendMail({ 141 to: req.newEmailPuny, 142 subject: "【DELOGs】メールアドレス変更が承認されました", 143 text: emailChangeApprovedText({ newEmail: req.newEmailPuny }), 144 }); 145 } catch (e) { 146 console.error("[mail] approve notification failed:", e); 147 } 148 149 return { ok: true }; 150} 151 152export async function rejectEmailChangeRequestAction( 153 formData: FormData, 154): Promise<ActionResult> { 155 // 1) 認証 156 const ses = await lookupSessionFromCookie(); 157 if (!ses.ok) return { ok: false, message: "認証が必要です" }; 158 159 // 2) 入力検証 160 const parsed = idSchema.safeParse({ id: formData.get("id") }); 161 if (!parsed.success) return { ok: false, message: "IDが不正です" }; 162 163 // 3) 対象取得 164 const req = await prisma.emailChangeRequest.findUnique({ 165 where: { id: parsed.data.id }, 166 select: { id: true, status: true, departmentId: true }, 167 }); 168 if (!req) return { ok: false, message: "申請が見つかりません" }; 169 170 // 4) ADMIN & 同部署チェック(実効ロール) 171 let operatorName; 172 { 173 const g = await requireAdminSameDepartment(ses.userId, req.departmentId); 174 if (!g.ok) return g; 175 operatorName = g.meName; 176 } 177 178 if (!["PENDING", "VERIFIED"].includes(req.status)) 179 return { ok: false, message: "未処理の申請のみ却下できます" }; 180 181 // 5) 競合対策: 条件付き更新 182 const updated = await prisma.emailChangeRequest.updateMany({ 183 where: { id: req.id, status: { in: ["PENDING", "VERIFIED"] } }, 184 data: { 185 status: "REJECTED", 186 processedAt: new Date(), 187 processedBy: operatorName, 188 }, 189 }); 190 if (updated.count !== 1) return { ok: false, message: "競合が発生しました" }; 191 192 return { ok: true }; 193}
今回の修正では、最初に Zod スキーマを z.uuid() に揃え、ID 検証を明確化しています。権限ガードは小さな関数 requireAdminSameDepartment に切り出し、(1) 有効ユーザであること(2) 同一部署であること 、 *(3) 実効 priority がしきい値以上 *、の3点を順に確認します。 承認処理はトランザクション内で 条件付き更新(updateMany を使い、状態が変わっていないことを WHERE 句で保証して競合を検知します。メール送信は DB コミット後に実行し、失敗してもビジネス処理の成功を優先する設計です。却下処理も同様に、ガード → 状態チェック → 条件付き更新の流れで統一しました。
フロントエンド側の新しい状態フィルタとローカル反映ロジック(DataTable)と整合が取れるよう、成功時は statusprocessedAtprocessedBy をその場で上書きできる構造のままにしています。これにより、一覧は 即時反映 しつつ、サーバ側は レースセーフ に保てます。
以上で、メールアドレス変更申請一覧の変更の完成です。

4. まとめと次回予告

今回の記事では、プロフィール機能とメール変更申請一覧の双方を 部署別ロール対応(DepartmentRole) に統一し、実運用レベルの権限・UI整合を実現しました。
特に、メール変更申請一覧はこれまでのモック構成から脱却し、ユーザ一覧と同等の DataGrid 体験(フィルタ・CSV・即時反映) を備えるまでに進化しています。
状態フィルタは StatusMultiSelect の刷新により、表示データから動的に選択肢を生成する仕組みへと改善され、ロール・日付レンジと同様の UX が得られるようになりました。
また、サーバアクション側も getEffectiveRole() を使って 実効 priority に基づくセキュアな判定に統一され、一覧・操作・認可がすべて一貫したレイヤ構造で動作するよう整理されています。
txt
1───────────────────────────── 2🧩 改修後の構成イメージ 3───────────────────────────── 4Profile: SSR + FormData更新 + snapshot再同期 5 ├─ getMyProfileDetail() …… 実効ロール・電話番号を取得 6 ├─ updateProfileAction() … 氏名・電話・アバター更新 7 └─ profileUpdateSchema …… クライアント/サーバ共通バリデーション 8 9EmailChangeRequests: 10 ├─ page.tsx ………………… 登場状態を抽出し statusOptions を生成 11 ├─ StatusMultiSelect …… データ駆動型フィルタ(検索+全選択対応) 12 ├─ DataTable ……………… 状態・ロール・日付・検索を合成フィルタ 13 ├─ columns.tsx ………… HeaderWithFilter 統一構成でUI統合 14 └─ email-change-requests.ts … 実効priorityで承認/却下を処理 15─────────────────────────────
これにより、管理画面内の「ユーザ」「ロール」「メール申請」の3領域がすべて DepartmentRoleを起点とした一貫構造 に統合されました。
単に見た目やデータ構造を揃えただけでなく、ロール優先度・部署スコープ・操作ガードの概念が同じレイヤで機能するよう整理されたことで、以後の機能拡張もスムーズに行えます。

🔜 次回予告:メニューのDB連携(部署別ロールに応じた動的メニュー)

次回は、画面左のメニューを DB管理 に切り替え、DepartmentRole と実効 priority に沿って 動的に構成されるナビゲーション を実装します。メニュー階層・表示順・アイコン・遷移先・表示条件(権限/Feature Flag)をテーブルで管理し、SSR 時に最適化された形で配信する方針です。
あわせて「現在地のハイライト」「パンくず自動生成」「非許可パスの抑止」を同一スキーマ上で整合させ、運用中でも SQL だけで差し替え可能 なメニュー運用を目指します。

参考文献

種別資料名URL
ライブラリ公式TanStack Table v8 Documentationhttps://tanstack.com/table/v8
ライブラリ公式Zod – TypeScript-first schema validationhttps://zod.dev/
ライブラリ公式React Hook Form – Resolver Integrationhttps://react-hook-form.com/docs/useform/#resolver
UIコンポーネントshadcn/ui – Componentshttps://ui.shadcn.com/
デザイン参考Tailwind CSS Documentationhttps://tailwindcss.com/docs
データ管理Prisma ORM Documentationhttps://www.prisma.io/docs
認証基盤Next.js App Router / Server Actionshttps://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
実装参考date-fns – Modern JavaScript Date Utility Libraryhttps://date-fns.org/
文字コードRFC 3492: Punycode: A Bootstring encoding of Unicode for Internationalized Domain Nameshttps://datatracker.ietf.org/doc/html/rfc3492
メール送信Nodemailer Documentationhttps://nodemailer.com/about/
その他DELOGs 技術ブログ(プロフィール・権限管理シリーズ)https://delogs.jp/next-js/backend
今回の改修では、特に TanStack Table と shadcn/ui の柔軟な拡張性を活かし、DataGrid の状態同期・複合フィルタ・Popover UI を統一設計として再構築しました。
加えて、Prisma・Zod・Server Action の組み合わせにより、スキーマ定義からUI操作までを型安全に貫く実装パターン を確立できました。
こうした技術選定は、DELOGs 全体の「構成を共通化しながら進化できる管理画面」方針を支える基盤となっています。
この記事の執筆・編集担当
DE

松本 孝太郎

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

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