DELOGs
[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携する

管理画面フォーマット開発編 #7
ユーザ管理UIをDB連携する

ユーザ一覧表示・新規登録・編集フォームをDBと連動させ、ユーザデータを操作できる形へ

初回公開日

最終更新日

0. はじめに

これまでの管理画面フォーマット開発編では、DB設計(#1)、ログイン/ログアウト(#2〜#4)、ユーザプロフィール更新(#5)、RBACによるアクセス制御(#6)と、管理画面の基盤を順に整えてきました。
本記事ではこれらを前提として、ユーザ管理UIを 実際のDB(Prisma + PostgreSQL)と接続 し、新規登録・編集・一覧表示を可能にします。

本記事で扱う範囲

本稿の対象は「ユーザ管理」のうち 新規登録・編集・一覧表示 の3機能です。
メールアドレス変更やパスワード変更は既にプロフィール更新(#5)で実装済みのため、ここでは再度扱いません。
部署コンテキストはセッションから固定されるため、UIに部署選択は存在しません。
機能区分実装済み本記事で扱う
displayId自動採番✅ #1参照のみ
ログイン/ログアウト✅ #2〜#4-
ユーザプロフィール✅ #5-
RBAC制御✅ #6補完的に使用
ユーザ新規登録-
ユーザ編集-
ユーザ一覧表示-

技術スタック

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

1. ユーザ新規登録のDB連携

本章では、ユーザの新規登録を DB連携した実装 に拡張します。
/users/new ページからフォーム入力 → Server Action → Prisma で保存 → 一覧へ戻る、という流れを構築します。
重要なポイントは以下です。
  • フォームは既存の UserForm(RHF+Zod) を利用し、props.onSubmit(values) を呼ぶ設計をそのまま使う
  • phone / remarks を任意項目として Zod・フォーム・Server Action に追加
  • departmentId はセッションからは得られないため、userId からDBで再取得
  • emailはtrimのみ、ドメイン許可判定は既存の isDomainAllowed を利用
  • roleCode を受け取り、Server Action 内で role.id に解決して保存
txt
1処理フロー(ユーザ新規登録) 2 3[フォーム入力(UserForm)] 4 ↓ onSubmit(values) 5[client.tsx → createUser(values)] 67[create-user.ts(Server Action)] 8 1. セッション確認(lookupSessionFromCookie) 9 2. getUserSnapshot で canEditData 判定 10 3. userId から departmentId をDBで取得 11 4. email: trimのみ 12 5. isDomainAllowed(departmentId, email) で許可判定 13 6. 部署内重複チェック 14 7. roleCode → role.id に解決 15 8. password を argon2 でハッシュ化 16 9. phone / remarks を含めて Prisma で保存 1718[結果を返却 → 正常なら /users へリダイレクト]

スキーマの拡張

まず、userCreateSchemauserUpdateSchemaphone / remarks を追加します。いずれも任意項目なので .optional() とします。
ts
1src/lib/users/schema.ts(抜粋) 2 3const phoneSchema = z.string().max(50, "50文字以内で入力してください").optional(); 4const remarksSchema = z.string().max(255, "255文字以内で入力してください").optional(); 5 6/** ── 新規作成用:password が必須 ── */ 7export const userCreateSchema = z.object({ 8 name: nameSchema, 9 email: emailSchema, 10 roleCode: roleCodeSchema, 11 password: passwordSchema, 12 isActive: z.boolean(), 13 phone: phoneSchema, // 追加 14 remarks: remarksSchema, // 追加 15}); 16 17/** ── 編集用:displayId を表示専用で扱い、password は扱わない ── */ 18export const userUpdateSchema = z.object({ 19 displayId: z.string().min(1, "表示IDの取得に失敗しました"), 20 name: nameSchema, 21 email: emailSchema, 22 roleCode: roleCodeSchema, 23 isActive: z.boolean(), 24 phone: phoneSchema, // 追加 25 remarks: remarksSchema, // 追加 26});
これにより UserCreateValues / UserUpdateValues の型にも phone / remarks が追加されます。

パスワード生成ツールの作成

管理者がユーザを新規登録する際に設定するパスワードは初期パスワードで、運用時には各ユーザが自身で好きなパスワードを設定することを想定しています。よって、管理者がパスワードをどうするか悩む必要がないように生成機能を追加したいと思います。
ts
1// src/lib/security/password.ts(パスワード生成:クライアントでも使えるユーティリティ) 2export function generatePassword(length = 20): string { 3 const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 4 const lower = "abcdefghijklmnopqrstuvwxyz"; 5 const digits = "0123456789"; 6 const symbols = "!@#$%^&*()-_=+[]{}:,./?"; 7 const all = upper + lower + digits + symbols; 8 9 // 各種1文字は必ず含める 10 const must = [ 11 upper[Math.floor(Math.random() * upper.length)], 12 lower[Math.floor(Math.random() * lower.length)], 13 digits[Math.floor(Math.random() * digits.length)], 14 symbols[Math.floor(Math.random() * symbols.length)], 15 ]; 16 const rest = Array.from({ length: Math.max(0, length - must.length) }, () => { 17 return all[Math.floor(Math.random() * all.length)]; 18 }); 19 const chars = [...must, ...rest]; 20 21 // シャッフル 22 for (let i = chars.length - 1; i > 0; i--) { 23 const j = Math.floor(Math.random() * (i + 1)); 24 [chars[i], chars[j]] = [chars[j], chars[i]]; 25 } 26 return chars.join(""); 27}

フォームへのフィールド追加とパスワード生成ボタンの追加

UserFormPhoneField / RemarksField を追加します。CreateForm / EditForm 双方に組み込みます。また、前節で作成したパスワード生成機能も組み込みます。
まず、src/components/users/user-form.tsxの末尾に入力フィールドを追記します。
tsx
1// src/components/users/user-form.tsx(抜粋、末尾に追加) 2 3// 電話番号 4function PhoneField() { 5 return ( 6 <FormField 7 name="phone" 8 render={({ field }) => ( 9 <FormItem> 10 <FormLabel className="font-semibold">電話番号</FormLabel> 11 <FormControl> 12 <Input 13 {...field} 14 placeholder="090-xxxx-xxxx" 15 aria-label="電話番号" 16 autoComplete="off" 17 data-testid="phone" 18 /> 19 </FormControl> 20 <FormMessage data-testid="phone-error" /> 21 </FormItem> 22 )} 23 /> 24 ); 25} 26 27// 備考 28function RemarksField() { 29 return ( 30 <FormField 31 name="remarks" 32 render={({ field }) => ( 33 <FormItem> 34 <FormLabel className="font-semibold">備考</FormLabel> 35 <FormControl> 36 <Input 37 {...field} 38 placeholder="メモなど" 39 aria-label="備考" 40 data-testid="remarks" 41 /> 42 </FormControl> 43 <FormMessage data-testid="remarks-error" /> 44 </FormItem> 45 )} 46 /> 47 ); 48}
これを同じsrc/components/users/user-form.tsx内の CreateForm / EditForm の <CardContent> に組み込みます。
ts
1// src/components/users/user-form.tsx(抜粋) 2 3function CreateForm({ roleOptions, onSubmit, onCancel }: CreateProps) { 4 const form = useForm<UserCreateValues>({ 5 resolver: zodResolver(userCreateSchema), 6 defaultValues: { 7 name: "", 8 email: "", 9 password: "", 10 isActive: true, 11 phone: "", 12 remarks: "", 13 }, 14 mode: "onBlur", 15 }); 16 17 const handleSubmit = form.handleSubmit(onSubmit); 18 19 return ( 20 <Form {...form}> 21 <form data-testid="user-form-create" onSubmit={handleSubmit}> 22 <Card className="w-full rounded-md"> 23 <CardContent className="space-y-6 pt-1"> 24 <NameField /> 25 <EmailField /> 26 <RoleField roleOptions={roleOptions} /> 27 <PasswordField /> 28 <IsActiveField /> 29 <PhoneField /> {/* 追加 */} 30 <RemarksField /> {/* 追加 */} 31 </CardContent> 32 33// ── 省略 34 35function EditForm({ 36 roleOptions, 37 onSubmit, 38 onCancel, 39 onDelete, 40 initialValues, 41}: EditProps) { 42 const form = useForm<UserUpdateValues>({ 43 resolver: zodResolver(userUpdateSchema), 44 defaultValues: { 45 ...initialValues, // ← 書き方を変更 46 phone: initialValues.phone ?? "", // ← 追加 47 remarks: initialValues.remarks ?? "",// ← 追加 48 }, 49 mode: "onBlur", 50 }); 51 52 const handleSubmit = form.handleSubmit(onSubmit); 53 54 return ( 55 <Form {...form}> 56 <form data-testid="user-form-edit" onSubmit={handleSubmit}> 57 <Card className="w-full rounded-md"> 58 <CardContent className="space-y-6 pt-1"> 59 <DisplayIdField /> 60 <Separator /> 61 <NameField /> 62 <EmailField /> 63 <RoleField roleOptions={roleOptions} /> 64 <IsActiveField /> 65 <PhoneField /> {/* 追加 */} 66 <RemarksField /> {/* 追加 */} 67 </CardContent> 68 69// ── 省略 70
次に、フォームへパスワード生成ボタンを追加します。
ts
1// src/components/users/user-form.tsx(抜粋、PasswordField を置き換え) 2import { useForm, useFormContext } from "react-hook-form"; // useFormContextを追加 3import { generatePassword } from "@/lib/security/password"; // パスワード生成関数 4 5// …既存 import はそのまま… 6 7// パスワード(新規のみ・表示/非表示トグル付き) 8function PasswordField() { 9 const [showPassword, setShowPassword] = React.useState(false); 10 const form = useFormContext(); // RHFのcontextから setValue/getValues を取得 11 return ( 12 <FormField 13 name="password" 14 render={({ field }) => ( 15 <FormItem> 16 <FormLabel className="font-semibold">パスワード&nbsp;*</FormLabel> 17 <div className="flex items-start gap-2"> 18 <FormControl> 19 <Input 20 {...field} 21 data-testid="password" 22 type={showPassword ? "text" : "password"} 23 autoComplete="off" 24 placeholder={`${PASSWORD_MIN}文字以上(英大/小/数字を含む)`} 25 aria-label="パスワード" 26 /> 27 </FormControl> 28 29 <Button 30 data-testid="password-toggle" 31 type="button" 32 size="icon" 33 variant="outline" 34 onClick={() => setShowPassword((prev) => !prev)} 35 aria-label={ 36 showPassword 37 ? "パスワードを非表示にする" 38 : "パスワードを表示する" 39 } 40 className="shrink-0 cursor-pointer" 41 > 42 {showPassword ? ( 43 <EyeOff className="size-4" /> 44 ) : ( 45 <Eye className="size-4" /> 46 )} 47 </Button> 48 49 {/* 追加:生成ボタン */} 50 <Button 51 type="button" 52 variant="secondary" 53 className="cursor-pointer" 54 onClick={() => { 55 const pw = generatePassword(20); 56 form.setValue("password", pw, { 57 shouldValidate: true, 58 shouldDirty: true, 59 }); 60 }} 61 data-testid="password-generate" 62 > 63 自動生成 64 </Button> 65 </div> 66 <FormMessage data-testid="password-error" /> 67 </FormItem> 68 )} 69 /> 70 ); 71}

サーバアクションの実装

次に、ユーザ作成の Server Action を新規に追加します。ここで email正規化 / ドメイン許可判定 / roleCode解決 / phone・remarks保存 を行います。また、ユーザ登録と同時に対象ユーザへログイン情報をメール通知するようにします。
まずは、メールテンプレートを追加します。
ts
1// src/lib/email/templates.ts(追加部分のみの抜粋) 2 3export function buildLoginUrl() { 4 const origin = APP_ORIGIN ?? "http://localhost:3000"; 5 return new URL("/", origin).toString(); 6} 7 8 9/** 10 * ユーザ登録完了メール 11 */ 12export function userWelcomeText(params: { 13 name: string; 14 email: string; 15 departmentCode: string; 16 initialPassword: string; 17}) { 18 const loginUrl = buildLoginUrl(); 19 return [ 20 "DELOGsシステムより自動送信しています。このメールへの返信は受け付けていません。", 21 "", 22 `${params.name}`, 23 "", 24 "アカウントが作成されました。以下の情報でログインしてください。", 25 "", 26 `ログインURL:${loginUrl}`, 27 `部署コード :${params.departmentCode}`, 28 `メール   :${params.email}`, 29 `初期パスワード:${params.initialPassword}`, 30 "", 31 "※ 初回ログイン後にパスワードを変更してください。", 32 "※ このメールに心当たりがない場合は、管理者へお問い合わせください。", 33 ].join("\n"); 34}
次にユーザ登録のサーバアクションを作成します。
ts
1// src/app/_actions/users/create-user.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 7import { isDomainAllowed } from "@/lib/email/domain-allow"; 8import { userCreateSchema, type UserCreateValues } from "@/lib/users/schema"; 9import argon2 from "argon2"; 10 11type ActionResult = { ok: true } | { ok: false; message: string }; 12 13export async function createUser( 14 values: UserCreateValues, 15): Promise<ActionResult> { 16 // 1) 認証 17 const ses = await lookupSessionFromCookie(); 18 if (!ses.ok) return { ok: false, message: "認証が必要です。" }; 19 20 // 2) 権限 21 const snap = await getUserSnapshot(ses.userId); 22 if (!snap || !snap.canEditData) { 23 return { ok: false, message: "この操作を行う権限がありません。" }; 24 } 25 26 // 3) 所属部署ID 27 const me = await prisma.user.findUnique({ 28 where: { id: ses.userId }, 29 select: { departmentId: true }, 30 }); 31 if (!me) return { ok: false, message: "ユーザ情報を取得できませんでした。" }; 32 33 // 4) サーバ側の最終チェック(詳細は返さない) 34 const parsed = userCreateSchema.safeParse(values); 35 if (!parsed.success) { 36 return { ok: false, message: "入力内容を確認してください。" }; 37 } 38 const { name, email, roleCode, password, isActive, phone, remarks } = 39 parsed.data; 40 41 // 5) email 正規化(trim のみ) 42 const normalizedEmail = email.trim(); 43 44 // 6) 許可ドメイン 45 const domainOk = await isDomainAllowed(me.departmentId, normalizedEmail); 46 if (!domainOk) { 47 return { ok: false, message: "このドメインは許可されていません。" }; 48 } 49 50 // 7) 部署内重複 51 const dup = await prisma.user.findFirst({ 52 where: { departmentId: me.departmentId, email: normalizedEmail }, 53 select: { id: true }, 54 }); 55 if (dup) { 56 return { ok: false, message: "このメールアドレスは既に登録されています。" }; 57 } 58 59 // 8) roleCode → roleId 60 const role = await prisma.role.findUnique({ 61 where: { code: roleCode }, 62 select: { id: true }, 63 }); 64 if (!role) { 65 return { ok: false, message: "不正なロールが指定されました。" }; 66 } 67 68 // 9) パスワードハッシュ 69 const hashedPassword = await argon2.hash(password); 70 71 // 10) 登録 72 await prisma.user.create({ 73 data: { 74 departmentId: me.departmentId, 75 roleId: role.id, 76 email: normalizedEmail, 77 hashedPassword, 78 name, 79 isActive, 80 phone: phone || null, 81 remarks: remarks || null, 82 failedLoginCount: 0, 83 }, 84 }); 85 86 return { ok: true }; 87}
上記の Server Action は次の点が重要です:
  • lookupSessionFromCookie は userId までしか返さない → departmentId は DB で取得する
  • getUserSnapshot を呼び、RBACで定義済みの canEditData を確認する
  • 許可ドメイン判定は 既存の isDomainAllowed を再利用する
  • Prisma での user.create は DB 側の displayId 自動採番に任せる

Client コンポーネントの実装

client.tsx では onSubmit(values) を実装し、Server Action createUser(values) を呼びます。正常時は /users に遷移します。 これまではaccountCodeを定数で利用してきましたが、これもDB連携するようになりましたので削除します。
tsx
1// src/app/(protected)/users/new/client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import UserForm, { type RoleOption } from "@/components/users/user-form"; 6import type { UserCreateValues } from "@/lib/users/schema"; 7import { createUser } from "@/app/_actions/users/create-user"; 8import { toast } from "sonner"; 9 10type Props = { 11 roleOptions: RoleOption[]; 12}; 13 14export default function NewUserClient({ roleOptions }: Props) { 15 const router = useRouter(); 16 17 const handleSubmit = async (values: UserCreateValues) => { 18 const res = await createUser(values); 19 20 if (res.ok) { 21 toast.success("ユーザを作成しました", { 22 description: `${values.email}(ロール: ${values.roleCode}`, 23 duration: 3000, 24 }); 25 router.push("/users"); 26 router.refresh(); 27 return; 28 } 29 30 // 失敗時:汎用メッセージのみ(詳細なフィールド別メッセージはクライアント検証に委譲) 31 toast.error( 32 res.message ?? "登録に失敗しました。入力内容を確認してください。", 33 { 34 duration: 3500, 35 }, 36 ); 37 }; 38 39 return ( 40 <UserForm 41 mode="create" 42 roleOptions={roleOptions} 43 onSubmit={handleSubmit} 44 onCancel={() => history.back()} 45 /> 46 ); 47}

page コンポーネントの修正

Pageコンポーネントはほぼそのままなのですが、clientと同様に、accountCodeを定数で利用してきましたが、これもDB連携するようになりましたので削除します。また、ロールも同様にDBから取得したものを利用するように変更します。
tsx
1// src/app/(protected)/users/new/page.tsx 2import type { Metadata } from "next"; 3 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 Client from "./client"; 15// import { mockRoleOptions } from "@/lib/users/mock"; // ←削除 16 17// ★ 追加:SSRガード 18import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 19// ★ 追加:DB 20import { prisma } from "@/lib/database"; 21 22// const ACCOUNT_CODE = "testAccount0123"; // ←削除 23 24export const metadata: Metadata = { 25 title: "ユーザ新規登録", 26 description: 27 "共通フォーム(shadcn/ui + React Hook Form + Zod)でユーザを新規作成", 28}; 29 30export default async function Page() { 31 // ★ ここで表示可否を判定(未ログイン/権限不足/未定義は内部でredirect) 32 await guardHrefOrRedirect("/users/new", "/"); 33 34 // ★ DBからロール取得(有効ロールのみ、優先度順) 35 const roles = await prisma.role.findMany({ 36 where: { isActive: true }, 37 orderBy: { priority: "asc" }, 38 select: { code: true, name: true }, // Client 側が色も要るなら badgeColor も追加 39 }); 40 41 // ★ フォーム用オプションに整形 42 const roleOptions = roles.map((r) => ({ value: r.code, label: r.name })); 43 44 return ( 45 <> 46 <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"> 47 <div className="flex items-center gap-2 px-4"> 48 <SidebarTrigger className="-ml-1" /> 49 <Separator 50 orientation="vertical" 51 className="mr-2 data-[orientation=vertical]:h-4" 52 /> 53 <Breadcrumb> 54 <BreadcrumbList> 55 <BreadcrumbItem className="hidden md:block"> 56 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 57 </BreadcrumbItem> 58 <BreadcrumbSeparator className="hidden md:block" /> 59 <BreadcrumbItem> 60 <BreadcrumbPage>ユーザ新規登録</BreadcrumbPage> 61 </BreadcrumbItem> 62 </BreadcrumbList> 63 </Breadcrumb> 64 </div> 65 </header> 66 67 <div className="max-w-xl p-4 pt-0"> 68 {/* ★ mockを廃止してDBのロールを渡す */} 69 <Client roleOptions={roleOptions} /> 70 </div> 71 </> 72 ); 73}
これで「ユーザ新規登録」のDB連携が完成しました。次章ではユーザ編集に進みます。

2. ユーザ編集のDB連携

本章では、既存の「UIのみ」だったユーザ編集ページ(/users/[displayId])を Prisma + Server Action と接続し、DBの実データを読み書きできるようにします。
基本方針は 1章と同じく、フォームは既存の UserForm を流用し、サーバ側で 権限ガード / 入力検証 / 正規化 / 重複・許可ドメインチェック / 更新 を行います。
また、論理削除(deletedAt) に対応する Server Action も実装し、モック操作を廃止します。

仕様の整理(編集時のガード・検証・整合性)

編集は管理者(ADMIN)前提で、自部署配下ユーザのみを操作可能にします。入力検証は 既存の userUpdateSchema を用い、メールは toAsciiEmailSafe による punycode ASCII 正規化 を通過済みです。
観点方針
対象ユーザ取得displayId からユーザ1件を検索(deletedAt IS NULL
権限ガードログイン必須 + ADMIN かつ 同一 departmentId のユーザに限定
入力検証userUpdateSchema(emailはtransform→z.email()で検証済)
ドメイン許可isDomainAllowed(departmentId, email)(部署0件なら無制限)
重複チェック同部署内で email の重複を禁止(自分自身を除外)
正規化emailはUI/Zodでpunycode ASCII、サーバでは trim() のみでOK
論理削除deletedAt = now() を設定し一覧から除外(復活は別機能)
メール比較・保存は ASCII(punycode) を不変とし、UI表示時のみ toUnicode にする方針を継続します。

Server Action の追加(更新・論理削除)

ここでは、_actions/users/update-user.ts を新規作成し、更新論理削除 を提供します。
どちらも セッション確認→権限確認(ADMIN & 同一部署) を通過した上で処理します。
ts
1// src/app/_actions/users/update-user.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { userUpdateSchema, type UserUpdateValues } from "@/lib/users/schema"; 7import { isDomainAllowed } from "@/lib/email/domain-allow"; 8import { toAsciiEmailSafe } from "@/lib/email/normalize"; 9 10type ActionResult = { ok: true } | { ok: false; message: string }; 11 12/** 13 * 表示IDから対象ユーザを取得(削除済みは除外) 14 */ 15async function findEditableUserByDisplayId(displayId: string) { 16 return prisma.user.findFirst({ 17 where: { displayId, deletedAt: null }, 18 select: { 19 id: true, 20 displayId: true, 21 email: true, 22 name: true, 23 role: { select: { id: true, code: true } }, 24 departmentId: true, 25 isActive: true, 26 phone: true, 27 remarks: true, 28 }, 29 }); 30} 31 32/** 33 * 管理者(ADMIN)かつ同一部署かをチェック 34 */ 35async function requireAdminSameDepartment( 36 userId: string, 37 departmentId: string, 38) { 39 const me = await prisma.user.findUnique({ 40 where: { id: userId }, 41 select: { 42 id: true, 43 isActive: true, 44 departmentId: true, 45 role: { select: { code: true } }, 46 }, 47 }); 48 if (!me || !me.isActive) 49 return { ok: false as const, message: "ユーザが無効化されています。" }; 50 if (me.role.code !== "ADMIN") 51 return { ok: false as const, message: "権限がありません。" }; 52 if (me.departmentId !== departmentId) 53 return { ok: false as const, message: "越権操作です。" }; 54 return { ok: true as const }; 55} 56 57/** 58 * ユーザ情報の更新 59 */ 60export async function updateUserAction( 61 values: UserUpdateValues, 62): Promise<ActionResult> { 63 // 1) 認証 64 const ses = await lookupSessionFromCookie(); 65 if (!ses.ok) return { ok: false, message: "認証が必要です。" }; 66 67 // 2) 対象取得 68 const target = await findEditableUserByDisplayId(values.displayId); 69 if (!target) return { ok: false, message: "対象ユーザが見つかりません。" }; 70 71 // 3) 権限(ADMIN & 同部署) 72 { 73 const g = await requireAdminSameDepartment(ses.userId, target.departmentId); 74 if (!g.ok) return g; 75 } 76 77 // 4) 検証(email は UI で punycode ASCII に正規化済み) 78 const parsed = userUpdateSchema.safeParse(values); 79 if (!parsed.success) 80 return { ok: false, message: "入力内容を確認してください。" }; 81 82 const { name, email, roleCode, isActive, phone, remarks } = parsed.data; 83 84 // ★ 追加:最終防衛としてサーバ側でも punycode ASCII に正規化(冪等) 85 const asciiEmail = toAsciiEmailSafe(email).trim(); 86 87 // 5) 許可ドメイン判定(部署0件なら無制限) 88 const domainOk = await isDomainAllowed(target.departmentId, email.trim()); 89 if (!domainOk) 90 return { ok: false, message: "このドメインは許可されていません。" }; 91 92 // 6) メール重複(同部署・自身除外) 93 const dup = await prisma.user.findFirst({ 94 where: { 95 departmentId: target.departmentId, 96 email: asciiEmail, 97 deletedAt: null, 98 id: { not: target.id }, 99 }, 100 select: { id: true }, 101 }); 102 if (dup) 103 return { ok: false, message: "このメールアドレスは既に登録されています。" }; 104 105 // 7) roleCode → roleId 解決 106 const role = await prisma.role.findUnique({ 107 where: { code: roleCode }, 108 select: { id: true }, 109 }); 110 if (!role) return { ok: false, message: "不正なロールが指定されました。" }; 111 112 // ★ 唯一の ADMIN を失わせる更新はブロック 113 if (target.role.code === "ADMIN") { 114 // (1) ロールを ADMIN → 非 ADMIN に変更 115 if (roleCode !== "ADMIN") { 116 const activeAdminCount = await prisma.user.count({ 117 where: { 118 departmentId: target.departmentId, 119 deletedAt: null, 120 isActive: true, 121 role: { code: "ADMIN" }, 122 }, 123 }); 124 if (activeAdminCount <= 1) { 125 return { 126 ok: false, 127 message: 128 "この部署の有効な管理者(ADMIN)がこの1名のみのため、ADMIN以外のロールに変更できません。別の管理者を追加してから再試行してください。", 129 }; 130 } 131 } 132 133 // (2) 有効な ADMIN を無効化しようとした場合 134 if (isActive === false && target.isActive === true) { 135 const activeAdminCount = await prisma.user.count({ 136 where: { 137 departmentId: target.departmentId, 138 deletedAt: null, 139 isActive: true, 140 role: { code: "ADMIN" }, 141 }, 142 }); 143 if (activeAdminCount <= 1) { 144 return { 145 ok: false, 146 message: 147 "この部署の有効な管理者(ADMIN)がこの1名のみのため、無効化できません。別の管理者を追加してから再試行してください。", 148 }; 149 } 150 } 151 } 152 153 // 9) 更新 154 await prisma.user.update({ 155 where: { id: target.id }, 156 data: { 157 name, 158 email: asciiEmail, // ← punycode ASCII 159 roleId: role.id, 160 isActive, 161 phone: phone || null, 162 remarks: remarks || null, 163 }, 164 }); 165 166 return { ok: true }; 167} 168 169/** 170 * 論理削除(deletedAt を設定) 171 */ 172export async function deleteUserAction( 173 displayId: string, 174): Promise<ActionResult> { 175 // 1) 認証 176 const ses = await lookupSessionFromCookie(); 177 if (!ses.ok) return { ok: false, message: "認証が必要です。" }; 178 179 // 2) 対象取得 180 const target = await findEditableUserByDisplayId(displayId); 181 if (!target) return { ok: false, message: "対象ユーザが見つかりません。" }; 182 183 // 3) 権限(ADMIN & 同部署) 184 { 185 const g = await requireAdminSameDepartment(ses.userId, target.departmentId); 186 if (!g.ok) return g; 187 } 188 189 // 4) 「部署内の有効な ADMIN が1名しかいない」なら削除不可 190 // - ポリシー:isActive=true かつ deletedAt=null の ADMIN を“有効な管理者”とみなす 191 if (target.role.code === "ADMIN") { 192 const activeAdminCount = await prisma.user.count({ 193 where: { 194 departmentId: target.departmentId, 195 deletedAt: null, 196 isActive: true, 197 role: { code: "ADMIN" }, 198 }, 199 }); 200 201 if (activeAdminCount <= 1) { 202 return { 203 ok: false, 204 message: 205 "この部署の有効な管理者(ADMIN)がこの1名のみのため削除できません。別の管理者を作成してから再試行してください。", 206 }; 207 } 208 } 209 210 // 5) 論理削除 211 await prisma.user.update({ 212 where: { id: target.id }, 213 data: { deletedAt: new Date() }, 214 }); 215 216 return { ok: true }; 217}
上記では、 displayId から対象行を取得同部署のADMINか検証入力検証 / 許可ドメイン / 重複更新 という順に処理しています。
論理削除では deletedAt を設定し、以後の一覧・取得で除外されるようにします。 また、唯一のADMINがいなくなるような操作はブロックしておきます。

SSRで初期値をDBから取得(page.tsxの置き換え)

モックからの値生成を廃止し、SSR で DB を参照して初期値UserUpdateValues)を作ります。
この段階で 閲覧ガード(未ログイン/権限不足) も併せて行い、対象が無ければ notFound() に倒します。
tsx
1// src/app/(protected)/users/[displayId]/page.tsx 2 3import type { Metadata } from "next"; 4import { notFound } from "next/navigation"; 5import { prisma } from "@/lib/database"; 6import * as punycode from "punycode/"; 7 8import { 9 Breadcrumb, 10 BreadcrumbItem, 11 BreadcrumbLink, 12 BreadcrumbList, 13 BreadcrumbPage, 14 BreadcrumbSeparator, 15} from "@/components/ui/breadcrumb"; 16import { Separator } from "@/components/ui/separator"; 17import { SidebarTrigger } from "@/components/ui/sidebar"; 18import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 19import Client from "./client"; 20 21export const metadata: Metadata = { 22 title: "ユーザ編集", 23 description: "共通フォーム(shadcn/ui + RHF + Zod)でユーザ情報を編集", 24}; 25 26export default async function Page({ 27 params, 28}: { 29 params: Promise<{ displayId: string }>; 30}) { 31 const { displayId } = await params; 32 // ガードを通しつつ、返り値のスナップショットを受け取る 33 const viewer = await guardHrefOrRedirect(`/users/${displayId}`, "/"); 34 35 // 部署IDを userId から解決(スナップショットには含めない方針) 36 const me = await prisma.user.findUnique({ 37 where: { id: viewer.userId }, 38 select: { departmentId: true }, 39 }); 40 if (!me) notFound(); 41 42 // 対象ユーザを部署縛りで取得(論理削除は除外) 43 const row = await prisma.user.findFirst({ 44 where: { 45 displayId, 46 deletedAt: null, 47 departmentId: me.departmentId, // ★ 部署縛り 48 }, 49 select: { 50 displayId: true, 51 name: true, 52 email: true, // 保存は punycode ASCII 53 isActive: true, 54 phone: true, 55 remarks: true, 56 role: { select: { code: true } }, 57 }, 58 }); 59 60 if (!row) notFound(); 61 62 const initialValues = { 63 displayId: row.displayId, 64 name: row.name, 65 email: punycode.toUnicode(row.email), 66 roleCode: row.role.code, 67 isActive: row.isActive, 68 phone: row.phone ?? undefined, 69 remarks: row.remarks ?? undefined, 70 } as const; 71 72 // ロール選択肢もDBから取得 73 const roleOptions = ( 74 await prisma.role.findMany({ 75 where: { isActive: true }, 76 orderBy: { priority: "asc" }, 77 select: { code: true, name: true }, 78 }) 79 ).map((r) => ({ value: r.code, label: r.name })); 80 81 return ( 82 <> 83 <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"> 84 <div className="flex items-center gap-2 px-4"> 85 <SidebarTrigger className="-ml-1" /> 86 <Separator 87 orientation="vertical" 88 className="mr-2 data-[orientation=vertical]:h-4" 89 /> 90 <Breadcrumb> 91 <BreadcrumbList> 92 <BreadcrumbItem className="hidden md:block"> 93 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 94 </BreadcrumbItem> 95 <BreadcrumbSeparator className="hidden md:block" /> 96 <BreadcrumbItem> 97 <BreadcrumbPage>ユーザ情報編集({displayId}</BreadcrumbPage> 98 </BreadcrumbItem> 99 </BreadcrumbList> 100 </Breadcrumb> 101 </div> 102 </header> 103 104 <div className="max-w-xl p-4 pt-0"> 105 <Client initialValues={initialValues} roleOptions={roleOptions} /> 106 </div> 107 </> 108 ); 109}
ここでは、保存されている email は punycode ASCII である前提なので、閲覧専用表示ように toUnicode でUnicode変換して、initialValues.email に渡します。 また、部署コード(サービス提供の単位)はガード関数が出力するUserIdから取得して、他の顧客のデータが表示されないように縛りを掛けておきます。

クライアント結線(client.tsxの置き換え)

UIのみのトースト処理を、Server Action 呼び出しへ置き換えます。
成功時は一覧に戻し、失敗時は汎用メッセージでトースト表示します(詳細な項目別エラーはクライアント検証に委譲)。
tsx
1// src/app/(protected)/users/[displayId]/client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import { toast } from "sonner"; 6import UserForm, { type RoleOption } from "@/components/users/user-form"; 7import type { UserUpdateValues } from "@/lib/users/schema"; 8import { 9 updateUserAction, 10 deleteUserAction, 11} from "@/app/_actions/users/update-user"; 12 13type Props = { 14 initialValues: UserUpdateValues; 15 roleOptions: RoleOption[]; 16}; 17 18export default function EditUserClient({ initialValues, roleOptions }: Props) { 19 const router = useRouter(); 20 return ( 21 <UserForm 22 mode="edit" 23 initialValues={initialValues} 24 roleOptions={roleOptions} 25 onSubmit={async (values) => { 26 const res = await updateUserAction(values); 27 if (res.ok) { 28 toast.success("ユーザを更新しました", { 29 description: `ID: ${values.displayId} / ${values.email} / ロール: ${values.roleCode} / 有効: ${values.isActive ? "ON" : "OFF"}`, 30 duration: 3000, 31 }); 32 router.push("/users"); 33 router.refresh(); 34 return; 35 } 36 toast.error( 37 res.message ?? "更新に失敗しました。入力内容を確認してください。", 38 { 39 duration: 3500, 40 }, 41 ); 42 }} 43 onCancel={() => history.back()} 44 onDelete={async () => { 45 const res = await deleteUserAction(initialValues.displayId); 46 if (res.ok) { 47 toast.success("ユーザを論理削除しました", { 48 description: `ID: ${initialValues.displayId}`, 49 }); 50 router.push("/users"); 51 router.refresh(); 52 } else { 53 toast.error(res.message ?? "削除に失敗しました"); 54 } 55 }} 56 /> 57 ); 58}
onSubmit / onDelete ともに Server Action の成功/失敗でトースト を切り替え、成功時は /users へ戻ります。
メール正規化(punycode ASCII)userUpdateSchemaemailSchema が担っているため、クライアント側で特別な処理は不要です。
これで、ユーザ編集についても DB連携 に置き換えが完了しました。
メールアドレスは 入力直後から punycode ASCII に正規化されているため、 許可ドメイン照合・重複チェック・保存 が一貫して安定します。
次章では、 ユーザ一覧のDB連携 (ページング/フィルタ/ソート)に進みます。

3. ユーザ一覧のDB連携

この章では、UIのみ版の一覧を DB連携・権限制御・フィルタ/ソート・表示項目切替・CSV まで一気通貫で仕上げます。完成後は、実運用に耐える一覧 UX(固定ヘッダ、複合フィルタ、列表示のオン/オフ、複数ソート、CSV 連携)になります。

ゴールと完成イメージ

本章を終えると、次の要件を満たしたユーザ一覧が完成します。
項目できること実装ポイント
DB連携部署内・未削除ユーザのみ取得SSR + Prisma
フィルタロール・状態・登録/更新日の範囲・キーワード列ヘッダの Popover + TableMeta
ソートなし→降順→昇順→なし、複数ソート(Shift/Ctrl/⌘)SortButton
表示項目列のオン/オフ(チェックリスト)columnVisibility と同期
CSVフィルタ後×表示中の列のみを出力BOM付きUTF-8、Excel互換
a11y/UXsticky ヘッダ、キーボード、ARIAtable-container + ボタン aria-label
画面は、下図のようになります。
完成後のユーザ一覧の画面

型の下ごしらえ:TableMeta を拡張して“ヘッダUI→外側ステート”を橋渡し

列ヘッダ(Popover内)の UI から、DataTable 側の state を直接いじれるようにします。@tanstack/table-coreTableMeta を拡張し、setter を注入します。これにより、カラム定義は UI 表現に集中し、状態は DataTable で一元管理できます。
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} 33 34export {};
ポイント解説
  • DataTable から meta: { roles, setRoles, ... } を注入 → columns.tsx のヘッダ UI から直接参照可能。
  • 列ヘッダ UI は 「set◯◯を呼ぶだけ」 なので副作用の責務が明確になります。

共通 UI 部品とデータグリッドの下支えを用意

再利用可能なフィルタ UI は components/filters/ に、テーブルの共通補助は components/datagrid/ にまとめます。これにより他エンティティの一覧にも横展開しやすくなります。

Date Picker(期間指定)のフィルタ作成

tsx
1// src/components/filters/date-range-picker.tsx 2"use client"; 3 4import * as React from "react"; 5import { Calendar } from "@/components/ui/calendar"; 6import { Button } from "@/components/ui/button"; 7import * as PopoverPrimitive from "@radix-ui/react-popover"; 8import { X } from "lucide-react"; 9import { ja } from "date-fns/locale"; 10import type { DateRange } from "react-day-picker"; 11 12/** 画面幅が狭いときは1カ月、広いときは2カ月表示にする */ 13function useIsNarrow(breakpointPx = 768) { 14 const [narrow, setNarrow] = React.useState(false); 15 React.useEffect(() => { 16 const m = window.matchMedia(`(max-width:${breakpointPx}px)`); 17 const handler = () => setNarrow(m.matches); 18 handler(); // 初期反映 19 m.addEventListener?.("change", handler); 20 return () => m.removeEventListener?.("change", handler); 21 }, [breakpointPx]); 22 return narrow; 23} 24 25type Props = { 26 label: string; 27 value?: DateRange; 28 onChange: (range: DateRange | undefined) => void; 29 /** 追加のクラスが必要なら渡す(任意) */ 30 className?: string; 31}; 32 33export function DateRangePicker({ label, value, onChange, className }: Props) { 34 const narrow = useIsNarrow(); // 狭いときは1カ月表示 35 36 return ( 37 <div className={className}> 38 <div className="p-2"> 39 <Calendar 40 mode="range" 41 numberOfMonths={narrow ? 1 : 2} 42 selected={value} 43 locale={ja} 44 onSelect={(r) => onChange(r)} 45 aria-label={`${label}の期間を選択`} 46 // initialFocus は不要(現在は自動で適切にフォーカスされる) 47 /> 48 </div> 49 50 <div className="flex items-center justify-between border-t p-2"> 51 {/* 左:クリア(Popoverは閉じない) */} 52 <Button 53 variant="ghost" 54 size="sm" 55 onClick={() => onChange(undefined)} 56 type="button" 57 className="cursor-pointer" 58 > 59 クリア 60 </Button> 61 62 {/* 右:閉じる(RadixのCloseで親Popoverを閉じる) */} 63 <PopoverPrimitive.Close asChild> 64 <Button 65 variant="ghost" 66 size="sm" 67 type="button" 68 className="cursor-pointer" 69 > 70 <X className="mr-1 h-4 w-4" /> 71 閉じる 72 </Button> 73 </PopoverPrimitive.Close> 74 </div> 75 </div> 76 ); 77}
  • 小画面は 1 ヶ月、広い画面は 2 ヶ月表示。
  • shadcn/uiのカレンダーはmode="range"とするだけで、期間指定モードになります。
  • クリアと閉じるを明示し、操作迷子を防ぎます。

ロールフィルタの作成

tsx
1// src/components/filters/roles-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 type RoleOption = { value: string; label: string }; 17 18export function RolesChecklist({ 19 value, 20 onChange, 21 options, 22 footer, 23}: { 24 value: string[]; // 選択中の roleCode 群 25 onChange: (next: string[]) => void; 26 options: RoleOption[]; // 表示するロール 27 footer?: React.ReactNode; // 右下の「閉じる」ボタン等を呼び出し側で差し込める 28}) { 29 const [needle, setNeedle] = React.useState(""); 30 31 const toggle = (code: string) => { 32 const set = new Set(value); 33 if (set.has(code)) { 34 set.delete(code); 35 } else { 36 set.add(code); 37 } 38 onChange(Array.from(set)); 39 }; 40 41 const all = options.map((o) => o.value); 42 const allSelected = value.length === all.length; 43 const noneSelected = value.length === 0; 44 45 const filtered = React.useMemo(() => { 46 const q = needle.trim().toLowerCase(); 47 if (!q) return options; 48 return options.filter( 49 (o) => 50 o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q), 51 ); 52 }, [needle, options]); 53 54 return ( 55 <div className="flex max-h-[60vh] w-full flex-col"> 56 <div className="p-2"> 57 <Command shouldFilter={false}> 58 <CommandInput 59 value={needle} 60 onValueChange={setNeedle} 61 placeholder="ロールを検索…" 62 /> 63 <CommandEmpty>該当するロールがありません</CommandEmpty> 64 <CommandGroup heading="ロール(複数選択可)"> 65 {filtered.map((o) => { 66 const checked = value.includes(o.value); 67 return ( 68 <CommandItem 69 key={o.value} 70 onSelect={() => toggle(o.value)} 71 className="flex items-center gap-2" 72 > 73 <span 74 className="flex h-5 w-5 items-center justify-center rounded border" 75 aria-checked={checked} 76 role="checkbox" 77 > 78 {checked ? <Check className="h-3 w-3" /> : null} 79 </span> 80 <span className="truncate">{o.label}</span> 81 </CommandItem> 82 ); 83 })} 84 </CommandGroup> 85 </Command> 86 </div> 87 88 <Separator /> 89 90 <div className="flex items-center justify-between gap-2 p-2"> 91 <div className="flex gap-2"> 92 <Button 93 type="button" 94 variant="ghost" 95 size="sm" 96 onClick={() => onChange(all)} 97 disabled={allSelected} 98 className="cursor-pointer" 99 > 100 すべて 101 </Button> 102 <Button 103 type="button" 104 variant="ghost" 105 size="sm" 106 onClick={() => onChange([])} 107 disabled={noneSelected} 108 className="cursor-pointer" 109 > 110 クリア 111 </Button> 112 </div> 113 <div className="flex items-center">{footer}</div> 114 </div> 115 </div> 116 ); 117}
  • Command パレット UI で検索 + 複数選択。
  • role="checkbox"aria-checked を付け、支援技術に優しい実装。

ステータス(有効・無効)のフィルタ作成

tsx
1// src/components/filters/status-filter.tsx 2"use client"; 3 4import * as React from "react"; 5import { Button } from "@/components/ui/button"; 6import { Separator } from "@/components/ui/separator"; 7 8export type StatusValue = "ALL" | "ACTIVE" | "INACTIVE"; 9 10export function StatusFilter({ 11 value, 12 onChange, 13 footer, 14 className, 15 labels = { all: "すべて", active: "有効のみ", inactive: "無効のみ" }, 16}: { 17 value: StatusValue; 18 onChange: (next: StatusValue) => void; 19 /** 右下に「閉じる」などを差し込みたいときに使用(任意) */ 20 footer?: React.ReactNode; 21 className?: string; 22 /** 表示ラベルの差し替え(任意) */ 23 labels?: { all: string; active: string; inactive: string }; 24}) { 25 return ( 26 <div 27 className={["flex max-h-[60vh] w-full flex-col", className || ""].join( 28 " ", 29 )} 30 > 31 <div className="space-y-2 p-2"> 32 <Button 33 variant={value === "ALL" ? "default" : "outline"} 34 size="sm" 35 onClick={() => onChange("ALL")} 36 className="w-full cursor-pointer justify-start" 37 > 38 {labels.all} 39 </Button> 40 <Button 41 variant={value === "ACTIVE" ? "default" : "outline"} 42 size="sm" 43 onClick={() => onChange("ACTIVE")} 44 className="w-full cursor-pointer justify-start" 45 > 46 {labels.active} 47 </Button> 48 <Button 49 variant={value === "INACTIVE" ? "default" : "outline"} 50 size="sm" 51 onClick={() => onChange("INACTIVE")} 52 className="w-full cursor-pointer justify-start" 53 > 54 {labels.inactive} 55 </Button> 56 </div> 57 58 <Separator /> 59 60 <div className="flex items-center justify-end gap-2 p-2">{footer}</div> 61 </div> 62 ); 63}
  • 単一選択の状態フィルタ。labels で文言差し替えも可能です。

表示項目の切り替えフィルタの作成

これは、一覧に表示する項目をユーザが変更する機能です。
tsx
1// src/components/filters/columns-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 type ColumnOption = { value: string; label: string }; 17 18export function ColumnsChecklist({ 19 value, 20 onChange, 21 options, 22 footer, 23 title = "表示項目(複数選択可)", 24}: { 25 value: string[]; // 選択中の columnId 群 26 onChange: (next: string[]) => void; 27 options: ColumnOption[]; // 表示可能な列 28 footer?: React.ReactNode; // 右下の閉じるボタン等 29 title?: string; 30}) { 31 const [needle, setNeedle] = React.useState(""); 32 33 const toggle = (id: string) => { 34 const set = new Set(value); 35 if (set.has(id)) set.delete(id); 36 else set.add(id); 37 onChange(Array.from(set)); 38 }; 39 40 const all = options.map((o) => o.value); 41 const allSelected = value.length === all.length; 42 const noneSelected = value.length === 0; 43 44 const filtered = React.useMemo(() => { 45 const q = needle.trim().toLowerCase(); 46 if (!q) return options; 47 return options.filter( 48 (o) => 49 o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q), 50 ); 51 }, [needle, options]); 52 53 return ( 54 <div className="flex max-h-[60vh] w-full flex-col"> 55 <div className="p-2"> 56 <Command shouldFilter={false}> 57 <CommandInput 58 value={needle} 59 onValueChange={setNeedle} 60 placeholder="項目を検索…" 61 /> 62 <CommandEmpty>該当する項目がありません</CommandEmpty> 63 <CommandGroup heading={title}> 64 {filtered.map((o) => { 65 const checked = value.includes(o.value); 66 return ( 67 <CommandItem 68 key={o.value} 69 onSelect={() => toggle(o.value)} 70 className="flex items-center gap-2" 71 > 72 <span 73 className="flex h-5 w-5 items-center justify-center rounded border" 74 aria-checked={checked} 75 role="checkbox" 76 > 77 {checked ? <Check className="h-3 w-3" /> : null} 78 </span> 79 <span className="truncate">{o.label}</span> 80 </CommandItem> 81 ); 82 })} 83 </CommandGroup> 84 </Command> 85 </div> 86 87 <Separator /> 88 89 <div className="flex items-center justify-between gap-2 p-2"> 90 <div className="flex gap-2"> 91 <Button 92 type="button" 93 variant="ghost" 94 size="sm" 95 onClick={() => onChange(all)} 96 disabled={allSelected} 97 className="cursor-pointer" 98 > 99 すべて 100 </Button> 101 <Button 102 type="button" 103 variant="ghost" 104 size="sm" 105 onClick={() => onChange([])} 106 disabled={noneSelected} 107 className="cursor-pointer" 108 > 109 クリア 110 </Button> 111 </div> 112 <div className="flex items-center">{footer}</div> 113 </div> 114 </div> 115 ); 116}
  • 列のオン/オフを“フィルタ条件の一部”として扱えるようにします。
  • 後述の columnVisibility と連動します。

ソート機能の追加

tsx
1// src/components/datagrid/sort-button.tsx 2"use client"; 3 4import * as React from "react"; 5import type { Column } from "@tanstack/react-table"; 6import { Button } from "@/components/ui/button"; 7import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react"; 8 9type Props<TData, TValue> = { 10 column: Column<TData, TValue>; 11 "aria-label"?: string; 12 title?: string; 13}; 14 15export function SortButton<TData, TValue>({ 16 column, 17 ...rest 18}: Props<TData, TValue>) { 19 const state = column.getIsSorted(); // false | 'asc' | 'desc' 20 const active = state === "asc" || state === "desc"; 21 22 const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { 23 // Shift / Ctrl / ⌘ 押下なら「複数ソート」モード 24 const multi = e.shiftKey || e.ctrlKey || e.metaKey; 25 26 const s = column.getIsSorted(); 27 if (!s) { 28 // none -> desc 29 column.toggleSorting(true, multi); 30 } else if (s === "desc") { 31 // desc -> asc 32 column.toggleSorting(false, multi); 33 } else { 34 // asc -> none(この列だけ解除。他列は保持される) 35 column.clearSorting(); 36 } 37 }; 38 39 const hint = 40 (rest.title ?? "") + (rest.title ? " / " : "") + "Shift/Ctrl/⌘で複数ソート"; 41 42 return ( 43 <Button 44 type="button" 45 size="icon" 46 variant={active ? "default" : "outline"} 47 className="h-7 w-7 cursor-pointer" 48 onClick={handleClick} 49 title={hint} 50 {...rest} 51 > 52 {state === "desc" ? ( 53 <ChevronDown className="h-3.5 w-3.5" /> 54 ) : state === "asc" ? ( 55 <ChevronUp className="h-3.5 w-3.5" /> 56 ) : ( 57 <ChevronsUpDown className="h-3.5 w-3.5" /> 58 )} 59 </Button> 60 ); 61}
  • 仕様:なし → 降順 → 昇順 → なし とボタンをクリックするたびに変化。
  • Shift/Ctrl/⌘ で複数列ソートを実現(一応、機能として入れました)。

テーブルヘッダを固定するため、テーブルコンポーネントのカスタム

これは、イレギュラーな対応です。shadcn/ui のテーブルコンポーネントをそのまま利用すると、stickyでテーブルヘッダの固定が上手くいきませんでした。調べてみると、function Tableのところで、overflow-x-autoつきのラッパで全体を括る仕様になっていました。 そこで、下記のようなカスタムコンポーネントを作成することにしました。
tsx
1// src/components/datagrid/table-container.tsx 2"use client"; 3 4import * as React from "react"; 5import { cn } from "@/lib/utils"; 6 7/** 8 * テーブル用スクロールコンテナ(縦横スクロール・固定ヘッダー用) 9 * - shadcn/ui の Table は使わず、<table> を直に包む 10 * - shadcn の TableHeader / TableHead / TableRow / TableCell はそのまま使える 11 */ 12 13type TableProps = React.ComponentProps<"table"> & { 14 /** ← 追加:外側のラッパ(div)に当てたいクラス */ 15 containerClassName?: string; 16}; 17 18export function Table({ className, containerClassName, ...props }: TableProps) { 19 return ( 20 <div 21 data-slot="table-container" 22 className={cn("relative w-full overflow-x-auto", containerClassName)} 23 > 24 <table 25 data-slot="table" 26 className={cn("w-full caption-bottom text-sm", className)} 27 {...props} 28 /> 29 </div> 30 ); 31}
  • theadsticky top-0 を当てるための素の <table> ラッパ。
  • shadcn/ui の Table 要素はそのまま併用可能です。

カラム定義:ヘッダにフィルタ&ソートを結線

columns.tsx では、ヘッダに Popover を出し分ける HeaderWithFilter / HeaderWithSort を用意し、SortButton と各フィルタ UI を結線します。
tsx
1// src/app/(protected)/users/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 { DateRangePicker } from "@/components/filters/date-range-picker"; 24import { RolesChecklist } from "@/components/filters/roles-checklist"; 25import { StatusFilter } from "@/components/filters/status-filter"; 26import { SortButton } from "@/components/datagrid/sort-button"; // ★ 追加 27 28export type UserRow = { 29 displayId: string; 30 name: string; 31 email: string; 32 roleCode: string; 33 roleName: string; 34 roleBadgeColor: string | null; 35 isActive: boolean; 36 phone: string | null; 37 remarks: string | null; 38 createdAt: Date; 39 updatedAt: Date; 40}; 41 42function fmt(dt: Date) { 43 return format(dt, "yyyy/MM/dd HH:mm", { locale: ja }); 44} 45 46function HeaderWithFilter({ 47 title, 48 active, 49 children, 50 contentClassName, 51 trailing, 52}: { 53 title: string; 54 active: boolean; 55 children: React.ReactNode; 56 /** ポップオーバの幅調整用(例:日付レンジで広めに) */ 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 を表示 */} 84 {trailing ? <div className="ml-0.5">{trailing}</div> : null} 85 </div> 86 ); 87} 88 89// ★ 追加:フィルタ無しのヘッダ用(タイトル + SortButton) 90function HeaderWithSort<TData, TValue>({ 91 title, 92 ctx, 93}: { 94 title: string; 95 ctx: HeaderContext<TData, TValue>; 96}) { 97 return ( 98 <div className="flex items-center gap-1"> 99 <span className="whitespace-nowrap">{title}</span> 100 <SortButton 101 column={ctx.column} 102 aria-label={`${title}でソート`} 103 title={`${title}でソート`} 104 /> 105 </div> 106 ); 107} 108 109export const columns: ColumnDef<UserRow>[] = [ 110 { 111 id: "actions", 112 header: "操作", 113 enableResizing: false, 114 size: 40, 115 enableSorting: false, 116 cell: ({ row }) => ( 117 <Tooltip> 118 <TooltipTrigger asChild> 119 <Button 120 asChild 121 size="icon" 122 variant="outline" 123 data-testid={`edit-${row.original.displayId}`} 124 className="size-8 cursor-pointer" 125 > 126 <Link href={`/users/${row.original.displayId}`}> 127 <SquarePen /> 128 </Link> 129 </Button> 130 </TooltipTrigger> 131 <TooltipContent> 132 <p>参照・編集</p> 133 </TooltipContent> 134 </Tooltip> 135 ), 136 }, 137 138 { 139 accessorKey: "displayId", 140 header: (ctx) => <HeaderWithSort title="表示ID" ctx={ctx} />, 141 cell: ({ row }) => ( 142 <span className="font-mono">{row.original.displayId}</span> 143 ), 144 }, 145 { 146 accessorKey: "name", 147 header: (ctx) => <HeaderWithSort title="氏名" ctx={ctx} />, 148 }, 149 { 150 accessorKey: "email", 151 header: (ctx) => <HeaderWithSort title="メール" ctx={ctx} />, 152 }, 153 { 154 accessorKey: "phone", 155 header: (ctx) => <HeaderWithSort title="電話" ctx={ctx} />, 156 }, 157 158 // ロール(フィルタ+ソート) 159 { 160 accessorKey: "roleCode", 161 header: (ctx) => { 162 const table = ctx.table; 163 const roleOptions = table.options.meta?.roleOptions ?? []; 164 const roles = 165 table.options.meta?.roles ?? roleOptions.map((o) => o.value); 166 const setRoles = table.options.meta?.setRoles ?? (() => {}); 167 const active = roles.length !== roleOptions.length; 168 169 return ( 170 <HeaderWithFilter 171 title="ロール" 172 active={active} 173 contentClassName="w-[340px]" 174 trailing={ 175 <SortButton 176 column={ctx.column} 177 aria-label="ロールでソート" 178 title="ロールでソート" 179 /> 180 } 181 > 182 <RolesChecklist 183 value={roles} 184 onChange={setRoles} 185 options={roleOptions} 186 footer={ 187 <PopoverPrimitive.Close asChild> 188 <Button 189 variant="ghost" 190 size="sm" 191 type="button" 192 className="cursor-pointer" 193 > 194 閉じる 195 </Button> 196 </PopoverPrimitive.Close> 197 } 198 /> 199 </HeaderWithFilter> 200 ); 201 }, 202 enableResizing: false, 203 size: 56, 204 cell: ({ row }) => { 205 const { roleCode, roleName, roleBadgeColor } = row.original; 206 const style = roleBadgeColor 207 ? { backgroundColor: roleBadgeColor, color: "#fff", border: "none" } 208 : undefined; 209 return ( 210 <Badge 211 variant={roleBadgeColor ? "secondary" : "default"} 212 style={style} 213 title={roleCode} 214 > 215 {roleName} 216 </Badge> 217 ); 218 }, 219 }, 220 221 // 状態(フィルタ+ソート) 222 { 223 accessorKey: "isActive", 224 header: (ctx) => { 225 const table = ctx.table; 226 const status = table.options.meta?.status ?? "ALL"; 227 const setStatus: (next: "ALL" | "ACTIVE" | "INACTIVE") => void = 228 table.options.meta?.setStatus ?? (() => {}); 229 const active = status !== "ALL"; 230 return ( 231 <HeaderWithFilter 232 title="状態" 233 active={active} 234 contentClassName="w-[220px]" 235 trailing={ 236 <SortButton 237 column={ctx.column} 238 aria-label="状態でソート" 239 title="状態でソート" 240 /> 241 } 242 > 243 <StatusFilter 244 value={status} 245 onChange={setStatus} 246 footer={ 247 <PopoverPrimitive.Close asChild> 248 <Button 249 variant="ghost" 250 size="sm" 251 type="button" 252 className="cursor-pointer" 253 > 254 閉じる 255 </Button> 256 </PopoverPrimitive.Close> 257 } 258 /> 259 </HeaderWithFilter> 260 ); 261 }, 262 enableResizing: false, 263 size: 50, 264 cell: ({ row }) => 265 row.original.isActive ? ( 266 <Badge data-testid="badge-active">有効</Badge> 267 ) : ( 268 <Badge variant="outline" data-testid="badge-inactive"> 269 無効 270 </Badge> 271 ), 272 }, 273 274 { 275 accessorKey: "remarks", 276 header: (ctx) => <HeaderWithSort title="備考" ctx={ctx} />, 277 size: 150, 278 cell: ({ row }) => ( 279 <div className="max-w-[150px] truncate">{row.original.remarks}</div> 280 ), 281 }, 282 283 // 登録日時(フィルタ+ソート) 284 { 285 accessorKey: "createdAt", 286 header: (ctx) => { 287 const table = ctx.table; 288 const r = table.options.meta?.createdRange; 289 const setR: ( 290 r: import("react-day-picker").DateRange | undefined, 291 ) => void = table.options.meta?.setCreatedRange ?? (() => {}); 292 const active = !!(r?.from || r?.to); 293 return ( 294 <HeaderWithFilter 295 title="登録日時" 296 active={active} 297 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]" 298 trailing={ 299 <SortButton 300 column={ctx.column} 301 aria-label="登録日時でソート" 302 title="登録日時でソート" 303 /> 304 } 305 > 306 <DateRangePicker label="登録日時" value={r} onChange={setR} /> 307 </HeaderWithFilter> 308 ); 309 }, 310 size: 120, 311 cell: ({ row }) => fmt(row.original.createdAt), 312 }, 313 314 // 更新日時(フィルタ+ソート) 315 { 316 accessorKey: "updatedAt", 317 header: (ctx) => { 318 const table = ctx.table; 319 const r = table.options.meta?.updatedRange; 320 const setR: ( 321 r: import("react-day-picker").DateRange | undefined, 322 ) => void = table.options.meta?.setUpdatedRange ?? (() => {}); 323 const active = !!(r?.from || r?.to); 324 return ( 325 <HeaderWithFilter 326 title="更新日時" 327 active={active} 328 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]" 329 trailing={ 330 <SortButton 331 column={ctx.column} 332 aria-label="更新日時でソート" 333 title="更新日時でソート" 334 /> 335 } 336 > 337 <DateRangePicker label="更新日時" value={r} onChange={setR} /> 338 </HeaderWithFilter> 339 ); 340 }, 341 size: 120, 342 cell: ({ row }) => fmt(row.original.updatedAt), 343 }, 344 345 // hidden 検索列(ソート不可のまま) 346 { 347 id: "q", 348 accessorFn: (r) => 349 `${r.displayId} ${r.name} ${r.email} ${r.phone} ${r.remarks}`.toLowerCase(), 350 enableHiding: true, 351 enableSorting: false, 352 enableResizing: false, 353 size: 0, 354 header: () => null, 355 cell: () => null, 356 }, 357];
  • すべてのヘッダで フィルタとソートのボタン配置を統一。
  • TableMeta の setter を呼ぶだけで、外側 state が更新されます。

一覧本体 DataTable:状態を一元管理し、描画・CSV・ページングまで

DataTable は “UI の司令塔”。フィルタ state を持ち、useMemoフィルタ後配列 を作り、TanStack Table へ渡します。列表示のオン/オフ(columnVisibility)と CSV もここで制御します。
tsx
1// src/app/(protected)/users/data-table.tsx 2"use client"; 3 4import * as React from "react"; 5import Link from "next/link"; 6import type { 7 ColumnDef, 8 SortingState, 9 VisibilityState, 10} from "@tanstack/react-table"; 11import { 12 flexRender, 13 getCoreRowModel, 14 getPaginationRowModel, 15 getSortedRowModel, 16 useReactTable, 17} from "@tanstack/react-table"; 18import { Input } from "@/components/ui/input"; 19import { 20 TableBody, 21 TableCell, 22 TableHead, 23 TableHeader, 24 TableRow, 25} from "@/components/ui/table"; 26import { Table } from "@/components/datagrid/table-container"; 27import { 28 Select, 29 SelectTrigger, 30 SelectContent, 31 SelectItem, 32 SelectValue, 33} from "@/components/ui/select"; 34import { Button } from "@/components/ui/button"; 35import type { DateRange } from "react-day-picker"; 36import { Eraser, FileDown, Columns3 } from "lucide-react"; // ★ 追加 37import { format } from "date-fns"; // ★ 追加 38import { ja } from "date-fns/locale"; // ★ 追加 39 40import type { UserRow } from "./columns"; 41import type { RoleOption } from "@/components/filters/roles-checklist"; 42import { 43 ColumnsChecklist, 44 type ColumnOption, 45} from "@/components/filters/columns-checklist"; // ★ 追加 46import { 47 Popover, 48 PopoverTrigger, 49 PopoverContent, 50} from "@/components/ui/popover"; // ★ 追加 51import * as PopoverPrimitive from "@radix-ui/react-popover"; 52 53type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE"; 54 55type Props = { 56 columns: ColumnDef<UserRow, unknown>[]; 57 data: UserRow[]; 58 roleOptions: RoleOption[]; // 一覧に登場するロールのみ 59 canDownloadData?: boolean; 60 canEditData?: boolean; 61}; 62 63export default function DataTable({ 64 columns, 65 data, 66 roleOptions, 67 canDownloadData = false, 68 canEditData = false, 69}: Props) { 70 const [q, setQ] = React.useState(""); 71 72 const [roles, setRoles] = React.useState<string[]>(() => 73 roleOptions.map((o) => o.value), 74 ); 75 const [status, setStatus] = React.useState<StatusFilter>("ALL"); 76 const [createdRange, setCreatedRange] = React.useState<DateRange>(); 77 const [updatedRange, setUpdatedRange] = React.useState<DateRange>(); 78 79 const [sorting, setSorting] = React.useState<SortingState>([ 80 { id: "createdAt", desc: true }, 81 ]); 82 83 // ★ 表示列の管理(初期値=現行の表示列) 84 // 画面で使っている列ID(actions と q は対象外) 85 const allColumnIds = React.useMemo( 86 () => 87 [ 88 "displayId", 89 "name", 90 "email", 91 "roleCode", 92 "isActive", 93 "phone", 94 "remarks", 95 "createdAt", 96 "updatedAt", 97 ] as const, 98 [], 99 ); 100 type ColId = (typeof allColumnIds)[number]; 101 102 const [visibleColumnIds, setVisibleColumnIds] = React.useState<ColId[]>( 103 () => [...allColumnIds], // 既定は全表示(必要なら初期非表示にする列を外す) 104 ); 105 106 // ★ 列ラベル(ヘッダ表示とCSV用) 107 const columnLabels = React.useMemo( 108 () => 109 ({ 110 displayId: "表示ID", 111 name: "氏名", 112 email: "メール", 113 roleCode: "ロール", 114 isActive: "状態", 115 phone: "電話番号", 116 remarks: "備考", 117 createdAt: "登録日時", 118 updatedAt: "更新日時", 119 }) as const, 120 [], 121 ); 122 123 // ★ チェックリストに渡す選択肢 124 const columnOptions: ColumnOption[] = React.useMemo( 125 () => 126 allColumnIds.map((id) => ({ 127 value: id, 128 label: columnLabels[id], 129 })), 130 [allColumnIds, columnLabels], 131 ); 132 133 // ★ TanStack の columnVisibility と同期 134 const columnVisibility = React.useMemo<VisibilityState>(() => { 135 const set = new Set(visibleColumnIds); 136 return { 137 actions: true, // 操作列は常に表示 138 q: false, // 検索用 hidden 列は常に非表示 139 displayId: set.has("displayId"), 140 name: set.has("name"), 141 email: set.has("email"), 142 roleCode: set.has("roleCode"), 143 isActive: set.has("isActive"), 144 phone: set.has("phone"), 145 remarks: set.has("remarks"), 146 createdAt: set.has("createdAt"), 147 updatedAt: set.has("updatedAt"), 148 }; 149 }, [visibleColumnIds]); 150 151 const filteredData = React.useMemo(() => { 152 const needle = q.trim().toLowerCase(); 153 const roleSet = new Set(roles); 154 const inRange = (d: Date, r?: DateRange) => { 155 if (!r?.from && !r?.to) return true; 156 const ts = d.getTime(); 157 if (r?.from && ts < new Date(r.from).setHours(0, 0, 0, 0)) return false; 158 if (r?.to && ts > new Date(r.to).setHours(23, 59, 59, 999)) return false; 159 return true; 160 }; 161 162 return data.filter((u) => { 163 const passQ = 164 !needle || 165 `${u.displayId} ${u.name} ${u.email} ${u.phone} ${u.remarks}` 166 .toLowerCase() 167 .includes(needle); 168 169 const passRole = 170 roles.length === 0 171 ? false 172 : roles.length === roleOptions.length 173 ? true 174 : roleSet.has(u.roleCode); 175 176 const passStatus = 177 status === "ALL" || 178 (status === "ACTIVE" ? u.isActive === true : u.isActive === false); 179 180 const passCreated = inRange(u.createdAt, createdRange); 181 const passUpdated = inRange(u.updatedAt, updatedRange); 182 183 return passQ && passRole && passStatus && passCreated && passUpdated; 184 }); 185 }, [data, q, roles, status, createdRange, updatedRange, roleOptions.length]); 186 187 const table = useReactTable({ 188 data: filteredData, 189 columns, 190 state: { sorting, columnVisibility }, 191 onSortingChange: setSorting, 192 getCoreRowModel: getCoreRowModel(), 193 getSortedRowModel: getSortedRowModel(), 194 getPaginationRowModel: getPaginationRowModel(), 195 initialState: { pagination: { pageIndex: 0, pageSize: 20 } }, 196 meta: { 197 roleOptions, 198 roles, 199 setRoles, 200 status, 201 setStatus, 202 createdRange, 203 setCreatedRange, 204 updatedRange, 205 setUpdatedRange, 206 }, 207 }); 208 209 const filteredCount = filteredData.length; 210 211 // サマリ(ラベルは roleOptions から解決) 212 const roleLabel = React.useMemo(() => { 213 const map = new Map(roleOptions.map((o) => [o.value, o.label])); 214 if (roles.length === 0) return "ロール: なし"; 215 if (roles.length === roleOptions.length) return "ロール: すべて"; 216 return `ロール: ${roles.map((v) => map.get(v) ?? v).join(", ")}`; 217 }, [roles, roleOptions]); 218 219 const statusText = 220 status === "ALL" 221 ? "状態: すべて" 222 : status === "ACTIVE" 223 ? "状態: 有効" 224 : "状態: 無効"; 225 226 const fmtRange = (r?: DateRange) => 227 r?.from && r?.to 228 ? `${r.from.toLocaleDateString()}-${r.to.toLocaleDateString()}` 229 : r?.from 230 ? `${r.from.toLocaleDateString()}-` 231 : r?.to 232 ? `-${r.to.toLocaleDateString()}` 233 : "すべて"; 234 235 const visibleColsText = visibleColumnIds 236 .map((id) => columnLabels[id]) 237 .join(", "); 238 239 const summary = `${roleLabel} / ${statusText} / 登録: ${fmtRange(createdRange)} / 更新: ${fmtRange(updatedRange)} / 表示: ${visibleColsText}`; 240 241 // 全フィルタ解除 242 const allRoles = roleOptions.map((o) => o.value); 243 const noFiltersApplied = 244 roles.length === allRoles.length && 245 status === "ALL" && 246 !createdRange?.from && 247 !createdRange?.to && 248 !updatedRange?.from && 249 !updatedRange?.to && 250 visibleColumnIds.length === allColumnIds.length; 251 252 const clearAllFilters = () => { 253 setRoles(allRoles); 254 setStatus("ALL"); 255 setCreatedRange(undefined); 256 setUpdatedRange(undefined); 257 setVisibleColumnIds([...allColumnIds]); // ★ 表示項目も全復帰 258 }; 259 260 // ★ CSV エクスポート(“現在表示中の列のみ”を出力) 261 const downloadCsv = React.useCallback(() => { 262 // 表示順はテーブルの「見えている葉カラム順」に合わせる 263 const visibleLeaf = table 264 .getVisibleLeafColumns() 265 .map((c) => c.id) 266 // UI用の列やhidden列は除外(actions, q) 267 .filter((id) => id !== "actions" && id !== "q") as ColId[]; 268 269 // ヘッダー 270 const headers = visibleLeaf.map((id) => columnLabels[id]); 271 272 const fmtDate = (d: Date) => format(d, "yyyy/MM/dd HH:mm", { locale: ja }); 273 const quote = (s: string) => 274 `"${String(s).replace(/"/g, '""').replace(/\r?\n/g, " ")}"`; 275 276 const rows = filteredData.map((u) => 277 visibleLeaf.map((id) => { 278 switch (id) { 279 case "displayId": 280 return u.displayId; 281 case "name": 282 return u.name; 283 case "email": 284 return u.email; 285 case "roleCode": 286 // 一覧の見た目はバッジ(name表示)なのでCSVは name を入れる 287 return u.roleName ?? u.roleCode; 288 case "isActive": 289 return u.isActive ? "有効" : "無効"; 290 case "phone": 291 return u.phone ?? ""; 292 case "remarks": 293 return u.remarks ?? ""; 294 case "createdAt": 295 return fmtDate(u.createdAt); 296 case "updatedAt": 297 return fmtDate(u.updatedAt); 298 default: 299 return ""; // 到達しない想定 300 } 301 }), 302 ); 303 304 const csv = [headers, ...rows] 305 .map((r) => r.map((c) => quote(c)).join(",")) 306 .join("\r\n"); 307 308 const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }); 309 const url = URL.createObjectURL(blob); 310 const a = document.createElement("a"); 311 const ts = format(new Date(), "yyyyMMdd_HHmmss"); 312 a.href = url; 313 a.download = `users_${ts}.csv`; 314 document.body.appendChild(a); 315 a.click(); 316 a.remove(); 317 URL.revokeObjectURL(url); 318 }, [filteredData, table, columnLabels]); 319 320 return ( 321 <div className="space-y-3"> 322 {/* 検索 + 新規 / CSV / 表示項目 */} 323 <div className="md: flex flex-wrap items-center justify-between gap-3 md:flex-nowrap"> 324 <Input 325 name="filter-q" 326 data-testid="filter-q" 327 value={q} 328 onChange={(e) => setQ(e.target.value)} 329 placeholder="氏名・メール・電話・備考・表示IDで検索" 330 className="w-[270px] basis-full text-sm md:basis-auto" 331 aria-label="検索キーワード" 332 /> 333 <div className="ml-auto flex items-center gap-2"> 334 {/* ★ 表示項目 */} 335 <Popover> 336 <PopoverTrigger asChild> 337 <Button 338 variant="outline" 339 className="cursor-pointer" 340 title="表示項目の変更" 341 > 342 <Columns3 className="h-4 w-4" /> 343 表示項目 344 </Button> 345 </PopoverTrigger> 346 <PopoverContent align="end" className="w-[320px] p-0"> 347 <ColumnsChecklist 348 value={visibleColumnIds} 349 onChange={(ids) => setVisibleColumnIds(ids as ColId[])} 350 options={columnOptions} 351 footer={ 352 <PopoverPrimitive.Close asChild> 353 <Button 354 variant="ghost" 355 size="sm" 356 type="button" 357 className="cursor-pointer" 358 > 359 閉じる 360 </Button> 361 </PopoverPrimitive.Close> 362 } 363 /> 364 </PopoverContent> 365 </Popover> 366 {/* ★ CSV */} 367 {canDownloadData && ( 368 <Button 369 variant="outline" 370 onClick={downloadCsv} 371 className="cursor-pointer" 372 > 373 <FileDown className="h-4 w-4" /> 374 CSV 375 </Button> 376 )} 377 {/* 新規登録 */} 378 {canEditData && ( 379 <Button asChild> 380 <Link href="/users/new">新規登録</Link> 381 </Button> 382 )} 383 </div> 384 </div> 385 386 {/* 件数・サマリ */} 387 <div className="flex items-center justify-between gap-3"> 388 <div className="text-sm" data-testid="count"> 389 表示件数: {filteredCount}390 </div> 391 392 {/* サマリ + 全フィルタ解除 */} 393 <div className="flex max-w-[60%] items-center justify-end gap-2"> 394 <div 395 className="text-muted-foreground truncate text-right text-xs" 396 title={summary} 397 > 398 {summary} 399 </div> 400 <Button 401 type="button" 402 variant="ghost" 403 size="sm" 404 onClick={clearAllFilters} 405 disabled={noFiltersApplied} 406 className="shrink-0 cursor-pointer" 407 title="全フィルタ解除" 408 > 409 <Eraser className="mr-1 h-4 w-4" /> 410 全フィルタ解除 411 </Button> 412 </div> 413 </div> 414 415 {/* テーブル */} 416 <div className="overflow-x-auto rounded-md border pb-1"> 417 <Table 418 data-testid="users-table" 419 className="w-full" 420 containerClassName={ 421 !canDownloadData && !canEditData 422 ? "max-h-[calc(100svh_-_244px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1" 423 : "max-h-[calc(100svh_-_284px)] md:max-h-[calc(100svh_-_224px)] overflow-y-auto pb-1" 424 } 425 > 426 <TableHeader className="bg-muted/60 supports-[backdrop-filter]:bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur"> 427 {table.getHeaderGroups().map((hg) => ( 428 <TableRow key={hg.id}> 429 {hg.headers.map((header) => ( 430 <TableHead 431 key={header.id} 432 style={{ width: header.column.getSize() }} 433 > 434 {header.isPlaceholder 435 ? null 436 : flexRender( 437 header.column.columnDef.header, 438 header.getContext(), 439 )} 440 </TableHead> 441 ))} 442 </TableRow> 443 ))} 444 </TableHeader> 445 <TableBody> 446 {table.getRowModel().rows.length ? ( 447 table.getRowModel().rows.map((row) => ( 448 <TableRow 449 key={row.id} 450 data-testid={`row-${(row.original as UserRow).displayId}`} 451 > 452 {row.getVisibleCells().map((cell) => ( 453 <TableCell 454 key={cell.id} 455 style={{ width: cell.column.getSize() }} 456 > 457 {flexRender( 458 cell.column.columnDef.cell, 459 cell.getContext(), 460 )} 461 </TableCell> 462 ))} 463 </TableRow> 464 )) 465 ) : ( 466 <TableRow> 467 <TableCell 468 colSpan={table.getAllColumns().length} 469 className="text-muted-foreground py-10 text-center text-sm" 470 > 471 条件に一致するユーザが見つかりませんでした。 472 </TableCell> 473 </TableRow> 474 )} 475 </TableBody> 476 </Table> 477 </div> 478 479 {/* ページング */} 480 <div className="flex items-center justify-between gap-3"> 481 {/* 左:ページサイズセレクト */} 482 <div className="flex items-center gap-2 text-sm"> 483 <span className="text-muted-foreground">1ページの表示件数</span> 484 <Select 485 value={String(table.getState().pagination.pageSize)} 486 onValueChange={(v) => table.setPageSize(Number(v))} 487 name="page-size" 488 > 489 <SelectTrigger className="w-[88px]"> 490 <SelectValue placeholder="件数" /> 491 </SelectTrigger> 492 <SelectContent> 493 {[20, 50, 100].map((n) => ( 494 <SelectItem key={n} value={String(n)}> 495 {n}496 </SelectItem> 497 ))} 498 </SelectContent> 499 </Select> 500 </div> 501 502 {/* 右:現在ページ / 総ページ + 前/次 */} 503 <div className="flex items-center gap-2"> 504 <span className="text-muted-foreground text-sm"> 505 Page {table.getState().pagination.pageIndex + 1} /{" "} 506 {table.getPageCount() || 1} 507 </span> 508 <Button 509 variant="outline" 510 size="sm" 511 onClick={() => table.previousPage()} 512 disabled={!table.getCanPreviousPage()} 513 data-testid="page-prev" 514 className="cursor-pointer" 515 > 516 前へ 517 </Button> 518 <Button 519 variant="outline" 520 size="sm" 521 onClick={() => table.nextPage()} 522 disabled={!table.getCanNextPage()} 523 data-testid="page-next" 524 className="cursor-pointer" 525 > 526 次へ 527 </Button> 528 </div> 529 </div> 530 </div> 531 ); 532}
  • 表示列のオン/オフvisibleColumnIdscolumnVisibility を同期。
  • CSV は getVisibleLeafColumns() を使い、 画面に見えている列順 で出力。
  • 初期ソートは createdAt desc(登録日の新しい順)。

CSVダウンロードについて

ダウンロードは、一覧の「いま見えている状態」をそのままダウンロードするようにしています。
本実装では (1) フィルタ済みデータ(2) 可視カラム順 で、(3) BOM 付き UTF-8 として保存します(Excel 互換)。
txt
1CSV 出力の中身 2- 行:filteredData(すべてのページ分) 3- 列:table.getVisibleLeafColumns() の順 4- 日付:yyyy/MM/dd HH:mm(ロケール ja) 5- ロール:name を出力(Badge の見た目に合わせる)
  • 権限 canDownloadData でボタン表示を切り替え可能。
  • 文字列のクォート/改行除去で CSV の崩れを防ぎます。

固定ヘッダ・高さ・レスポンシブ

sticky ヘッダが隠れないよう、table-containermax-hボタン群の有無 (CSV/新規)で微調整しています。ブラー背景でヘッダを視認しやすくし、縦長リストでも操作ストレスを減らします。
txt
1UI の見た目調整 2- <thead>:bg-muted/60 + sticky top-0 + backdrop-blur 3- container:overflow-y-auto + max-h(ボタン有無で差分) 4- TableCell:列幅は TanStack の column.size を尊重
  • スクロール位置を変えてもヘッダ操作(フィルタ/ソート)は常に届きます。

SSR ページ:部署縛りで取得し、表示用に整形

SSR で部署境界を越えないデータのみを取得し、表示用に整形して DataTable に渡します。メールは DB では ASCII(punycode)なので、表示時に Unicode 化します。
tsx
1// src/app/(protected)/users/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 * as punycode from "punycode/"; 16import DataTable from "./data-table"; 17import { columns, type UserRow } from "./columns"; 18 19export const metadata: Metadata = { 20 title: "ユーザ一覧", 21 description: 22 "Data table(shadcn/ui + @tanstack/react-table)でユーザ一覧を表示", 23}; 24 25export default async function Page() { 26 // 1) ページ閲覧ガード(未ログイン/権限不足なら内部でredirect) 27 const viewer = await guardHrefOrRedirect("/users", "/"); 28 29 // 2) 自分の departmentId を userId から解決(スナップショット非保持方針) 30 const me = await prisma.user.findUnique({ 31 where: { id: viewer.userId }, 32 select: { departmentId: true }, 33 }); 34 if (!me) return null; // 想定外 35 36 // 3) 部署内・未削除ユーザの取得(必要列のみ) 37 const rows = await prisma.user.findMany({ 38 where: { departmentId: me.departmentId, deletedAt: null }, 39 orderBy: { createdAt: "desc" }, // 既定は新しい順 40 select: { 41 displayId: true, 42 name: true, 43 email: true, // 保存は ASCII 44 isActive: true, 45 phone: true, 46 remarks: true, 47 createdAt: true, 48 updatedAt: true, 49 role: { select: { code: true, name: true, badgeColor: true } }, 50 }, 51 }); 52 53 // 4) 表示用に email を Unicode 化し、クライアント行型へ整形(ロール名・色も持たせる) 54 const users: UserRow[] = rows.map((r) => ({ 55 displayId: r.displayId, 56 name: r.name, 57 email: punycode.toUnicode(r.email), 58 roleCode: r.role.code, 59 roleName: r.role.name, 60 roleBadgeColor: r.role.badgeColor ?? null, 61 isActive: r.isActive, 62 phone: r.phone, 63 remarks: r.remarks, 64 createdAt: r.createdAt, 65 updatedAt: r.updatedAt, 66 })); 67 68 // 5) フィルタ用ロール選択肢(一覧に登場するロールだけ) 69 const roleOptions = Array.from( 70 new Map( 71 users.map((u) => [u.roleCode, { value: u.roleCode, label: u.roleName }]), 72 ).values(), 73 ); 74 75 return ( 76 <> 77 <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"> 78 <div className="flex items-center gap-2 px-4"> 79 <SidebarTrigger className="-ml-1" /> 80 <Separator 81 orientation="vertical" 82 className="mr-2 data-[orientation=vertical]:h-4" 83 /> 84 <Breadcrumb> 85 <BreadcrumbList> 86 <BreadcrumbItem className="hidden md:block"> 87 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 88 </BreadcrumbItem> 89 <BreadcrumbSeparator className="hidden md:block" /> 90 <BreadcrumbItem> 91 <BreadcrumbPage>ユーザ一覧</BreadcrumbPage> 92 </BreadcrumbItem> 93 </BreadcrumbList> 94 </Breadcrumb> 95 </div> 96 </header> 97 98 <div className="w-full max-w-[1729px] p-4 pt-0"> 99 <DataTable 100 columns={columns} 101 data={users} 102 roleOptions={roleOptions} 103 canDownloadData={viewer.canDownloadData} 104 canEditData={viewer.canEditData} 105 /> 106 </div> 107 </> 108 ); 109}
  • 取得は 部署内・未削除 のみ。
  • roleOptions は、一覧に現れるロールのみ抽出して軽量化。

動作確認チェックリスト

導入直後に崩れを見落とさないよう、次の観点で確認します。
txt
1□ 検索・ロール・状態・日付の各フィルタが独立して効く 2□ フィルタのサマリ表示が期待通り文言になる 3□ ソート:なし→降→昇→なし、複数ソート(Shift/Ctrl/⌘)が機能 4□ 表示項目:オン/オフがテーブルとCSVに反映される 5□ CSV:フィルタ後×可視列のみ、ヘッダ順は画面と同じ 6□ ページング:サイズ変更(20/50/100)と前後移動 7□ a11y:各ボタンの aria-label、チェックボックスの aria-checked 8□ 権限制御:canDownloadData/canEditData でボタン表示が切り替わる
ここまでで、一旦 「一覧の完成」 です。フィルタ機能をさらに扱いやすくするために、次章では、
  • URL クエリ(共有・ブックマーク・リロード耐性)
  • LocalStorage(個人の表示項目やソートの保持)
をハイブリッドで使い、編集→戻る でも同じ条件が復元されるようにします。

4. フィルタ状態の保存と復元 ─ URLクエリ + LocalStorageの共通フック化

本章では、ユーザ一覧をさらに実用的にするために フィルタ状態の保存と復元 を実装します。
ポイントは、特定画面だけでなく あらゆる一覧ページで使い回せる共通フック として整理することです。
完成すると、次のような体験が可能になります:
  • 一覧でフィルタやソートを設定 → URLに反映
  • 別ページに遷移して戻ると、前回の状態を再現
  • ローカル保存によって、ブラウザをリロードしても状態が保持

実装全体のマップ

まず、今回新規に作成・更新するファイルと役割を整理します。
区分パス目的新規/更新
フックsrc/lib/datagrid/use-datagrid-query-state.tsURLクエリと同期するための共通フック新規
フックsrc/lib/datagrid/use-persistent-datagrid-state.tsLocalStorageに保存/復元するための共通フック新規
一覧UIsrc/app/(protected)/users/data-table.tsx共通フックを呼び出し、状態を引き渡す更新(import先の切替)

共通フックの設計方針

一覧画面のフィルタは どのエンティティでも似たような構成(検索キーワード、ロール、状態、日付レンジ、表示列など)になるため、
重複を避けるためにフックを「汎用化」します。
  • useDatagridQueryState
    • URLクエリに状態を反映(例:?q=...&status=ACTIVE&roles=admin,user
    • ブラウザの戻る/進む操作でも復元できる
  • usePersistentDatagridState
    • LocalStorage に保存(例:datagrid:users のキーで状態を保持)
    • ページを再訪しても同じ状態から再開可能
この2つを組み合わせ、DataTable 側では単に 「フィルタ状態を読み書き」 するだけで済むようにします。
ts
1// src/lib/datagrid/use-datagrid-query-state.ts 2// URL <-> state 同期に加え、URLが空の復帰時は sessionStorage から復元 3"use client"; 4 5import * as React from "react"; 6import { usePathname, useRouter, useSearchParams } from "next/navigation"; 7 8type AnyState = Record<string, unknown>; 9 10type Options = { 11 /** URLに無いときの復元・退避に使うキー(例: "users") */ 12 persistKey?: string; 13 /** 退避先のストレージ。通常は戻る操作に強い session を推奨 */ 14 storage?: "session" | "local"; 15}; 16 17// --- JSONの安定化(キー昇順で直列化) --- 18function stableStringify(v: unknown): string { 19 if (v && typeof v === "object" && !Array.isArray(v)) { 20 const obj = v as Record<string, unknown>; 21 const sorted: Record<string, unknown> = {}; 22 for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]; 23 return JSON.stringify(sorted); 24 } 25 return JSON.stringify(v); 26} 27 28function encodeValue(v: unknown): string { 29 return encodeURIComponent(stableStringify(v)); 30} 31 32function decodeValue<T>(s: string | null, fallback: T): T { 33 if (!s) return fallback; 34 try { 35 const parsed = JSON.parse(decodeURIComponent(s)); 36 return parsed as T; 37 } catch { 38 return fallback; 39 } 40} 41 42function getStore(o?: Options) { 43 if (o?.storage === "local") 44 return typeof window !== "undefined" ? window.localStorage : undefined; 45 return typeof window !== "undefined" ? window.sessionStorage : undefined; 46} 47 48export function useDatagridQueryState<T extends AnyState>( 49 ns: string, 50 initial: T, 51 options?: Options, 52): [T, React.Dispatch<React.SetStateAction<T>>] { 53 const router = useRouter(); 54 const pathname = usePathname(); 55 const searchParams = useSearchParams(); 56 const store = getStore(options); 57 58 // 1) 初期化:URL > storage(persistKey) > initial の優先順位で読み込む 59 const [state, setState] = React.useState<T>(() => { 60 const fromUrl = decodeValue<T>(searchParams.get(ns), initial); 61 if (fromUrl !== initial) return fromUrl; 62 63 if (options?.persistKey && store) { 64 try { 65 const raw = store.getItem(`dg:${options.persistKey}`); 66 if (raw) { 67 const parsed = JSON.parse(raw) as T; 68 return { ...initial, ...parsed }; 69 } 70 } catch { 71 /* noop */ 72 } 73 } 74 return initial; 75 }); 76 77 // 2) 初回 Hydration 完了フラグ(SSR一致のため、初回はURL書き込みを抑制) 78 const mountedRef = React.useRef(false); 79 React.useEffect(() => { 80 mountedRef.current = true; 81 }, []); 82 83 // 3) URL 同期(等価なら何もしない) 84 React.useEffect(() => { 85 if (!mountedRef.current) return; 86 87 const sp = new URLSearchParams(searchParams.toString()); 88 const current = sp.get(ns); 89 const next = encodeValue(state); 90 if (current === next) return; 91 92 sp.set(ns, next); 93 router.replace(`${pathname}?${sp.toString()}`, { scroll: false }); 94 }, [ns, pathname, router, searchParams, state]); 95 96 // 4) storage 同期(戻る復元用) 97 React.useEffect(() => { 98 if (!options?.persistKey || !store) return; 99 try { 100 store.setItem(`dg:${options.persistKey}`, JSON.stringify(state)); 101 } catch { 102 /* noop */ 103 } 104 }, [state, options?.persistKey, store]); 105 106 return [state, setState]; 107}
上記フックは、指定したキー(例: "users")を先頭に付けて URL クエリに状態を保存します。
DataTable 側は、戻り値の [state, setState] を使って状態を制御できます。
ts
1// src/lib/datagrid/use-persistent-datagrid-state.ts 2"use client"; 3 4import * as React from "react"; 5 6export function usePersistentDatagridState<T>(key: string, initial: T) { 7 const [state, setState] = React.useState<T>(() => { 8 if (typeof window === "undefined") return initial; 9 try { 10 const raw = localStorage.getItem(`datagrid:${key}`); 11 return raw ? { ...initial, ...JSON.parse(raw) } : initial; 12 } catch { 13 return initial; 14 } 15 }); 16 17 React.useEffect(() => { 18 try { 19 localStorage.setItem(`datagrid:${key}`, JSON.stringify(state)); 20 } catch { 21 /* noop */ 22 } 23 }, [state, key]); 24 25 return [state, setState] as const; 26}
このフックは LocalStorage に状態を保存し、再訪問時に復元します。
保存キーは datagrid:users のようにエンティティごとに分離されます。

DataTable 側での利用方法

DataTable 側では、従来の useState をすべて共通フックに置き換えます。
これにより 状態保持の仕組みを意識せずに利用 できるようになります。

変更点の整理

UI/機能旧:data-table 内 useState新:useDatagridQueryState(URL)新:usePersistentDatagridState(LS)
キーワードqqueryState.q
ロールrolesqueryState.roles ※空配列=「すべて」
状態(ALL/ACTIVE/INACTIVE)statusqueryState.status
登録日レンジcreatedRangeDateRangequeryState.createdRange{ from?: ISO, to?: ISO } をURL保存。CSR後はローカル日付表示)
更新日レンジupdatedRangeDateRangequeryState.updatedRange{ from?: ISO, to?: ISO } をURL保存。CSR後はローカル日付表示)
表示列(チェックリスト)visibleColumnIdsqueryState.colsColId[] をURL保存)
ページサイズtable.getState().pagination.pageSizepersisted.pageSize(数値のみ保持)
※ 補足:useDatagridQueryState("users", initial, { persistKey: "users" }) により、URLが空の復帰時は sessionStorage から復元(戻る/進む対策)。
※ 並び順(sorting)は今回は対象外にします。
ただ、このまま src/app/(protected)/users/data-table.tsx に組み込むと非常に読みづらいファイルになってしまいます。 そこで、共通化できそうなものは外部ファイル化しながら、Data Table への組み込みを行います。

仕上げ:DataTable の責務分離と薄型化

次は DataTable を“薄く”して他画面にも横展開しやすく しつつ、作成済みの「URL+Storage 同期」機能を実際に組み込んでいきます。
やることはシンプルで、見た目単位の責務へ分割します:
  • ツールバー(検索・CSV・表示項目・新規ボタン)
  • サマリー(現在の絞り込み条件の一行表示)
  • ページング(件数切替・前/次)
  • 日付レンジの入出力・表示ユーティリティ
  • CSV 変換ユーティリティ
これで各一覧ページは「列定義・フィルタ定義・レイアウトの骨組み」だけを記述すれば済みます。
txt
1src/ 2├─ lib/ 3│ └─ datagrid/ 4│ ├─ use-datagrid-query-state.ts …(既存) 5│ ├─ use-persistent-datagrid-state.ts…(既存) 6│ ├─ date-io.ts … 日付レンジの入出力/表示 7│ └─ csv.ts … CSV 生成の共通処理 8└─ components/ 9 └─ datagrid/ 10 ├─ datagrid-toolbar.tsx … 検索・CSV・表示項目・新規 11 ├─ datagrid-summary.tsx … 一行サマリー 12 └─ datagrid-pagination.tsx … ページングUI

分割後の役割早見表

コンポーネント/ユーティリティ役割DataTable から渡すもの
datagrid-toolbar.tsx検索、表示項目ポップオーバ、CSV、新規q,setQ / 列候補と選択状態 / onDownloadCsv / newHref など
datagrid-summary.tsx一行の条件サマリ(ロール・状態・期間・表示列)ロール/状態/期間/列ラベル、mounted
datagrid-pagination.tsxページサイズ選択、前/次pageSize,setPageSizetable
date-io.tsURL⇄DateRange 変換、安定表示(ISO/ローカル)直接 import
csv.ts可視列のみのCSV出力(BOM付・日本語対応)直接 import

ソースコード

ts
1// src/lib/datagrid/date-io.ts 2import type { DateRange } from "react-day-picker"; 3import { format } from "date-fns"; 4import { ja } from "date-fns/locale"; 5 6export type StrDateRange = { from?: string; to?: string } | undefined; 7 8export const toDateRange = (src: StrDateRange): DateRange | undefined => 9 src 10 ? { 11 from: src.from ? new Date(src.from) : undefined, 12 to: src.to ? new Date(src.to) : undefined, 13 } 14 : undefined; 15 16export const fromDateRange = (r?: DateRange): StrDateRange => 17 r ? { from: r.from?.toISOString(), to: r.to?.toISOString() } : undefined; 18 19// サマリ用:SSR中は ISO(yyyy-MM-dd) で安定、CSR後はローカル表示に切替 20export const fmtIsoDay = (iso?: string) => (iso ? iso.slice(0, 10) : ""); 21export const fmtRangeStable = (r?: { from?: string; to?: string }) => 22 r?.from || r?.to ? `${fmtIsoDay(r?.from)}-${fmtIsoDay(r?.to)}` : "すべて"; 23 24export const fmtDayLocal = (d?: Date) => 25 d ? format(d, "yyyy-MM-dd", { locale: ja }) : ""; 26export const fmtRangeLocal = (r?: DateRange) => 27 r?.from || r?.to ? `${fmtDayLocal(r?.from)}-${fmtDayLocal(r?.to)}` : "すべて";
上は DataTable から そのまま import して使います。
SSR/CSR の表示ずれ対策(安定表示)もここに集約しました。
ts
1// src/lib/datagrid/csv.ts 2import { format } from "date-fns"; 3import { ja } from "date-fns/locale"; 4 5export type CsvRowObject = Record<string, string | number>; 6 7export function buildCsv( 8 headers: string[], 9 rows: (string | number)[][], 10): string { 11 const quote = (s: string | number) => 12 `"${String(s).replace(/"/g, '""').replace(/\r?\n/g, " ")}"`; 13 return [headers, ...rows].map((r) => r.map(quote).join(",")).join("\r\n"); 14} 15 16export function downloadCsv(filename: string, csv: string) { 17 const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }); 18 const url = URL.createObjectURL(blob); 19 const a = document.createElement("a"); 20 a.href = url; 21 a.download = filename; 22 document.body.appendChild(a); 23 a.click(); 24 a.remove(); 25 URL.revokeObjectURL(url); 26} 27 28export const fmtDateTime = (d: Date) => 29 format(d, "yyyy/MM/dd HH:mm", { locale: ja });
CSV の 生成とダウンロード を分離。DataTable 側は「可視列IDの配列」と「行の変換」だけ用意すればOKです。
tsx
1// src/components/datagrid/datagrid-toolbar.tsx 2"use client"; 3 4import * as React from "react"; 5import { Button } from "@/components/ui/button"; 6import { Input } from "@/components/ui/input"; 7import { 8 Popover, 9 PopoverTrigger, 10 PopoverContent, 11} from "@/components/ui/popover"; 12import * as PopoverPrimitive from "@radix-ui/react-popover"; 13import { Columns3, FileDown } from "lucide-react"; 14import Link from "next/link"; 15import { 16 ColumnsChecklist, 17 type ColumnOption, 18} from "@/components/filters/columns-checklist"; 19 20type Props<ColId extends string> = { 21 q: string; 22 onChangeQ: (v: string) => void; 23 columnOptions: ColumnOption[]; 24 visibleColumnIds: ColId[]; 25 onChangeVisibleColumns: (ids: ColId[]) => void; 26 canDownloadData?: boolean; 27 onDownloadCsv?: () => void; 28 canEditData?: boolean; 29 newHref?: string; 30}; 31 32export function DatagridToolbar<ColId extends string>({ 33 q, 34 onChangeQ, 35 columnOptions, 36 visibleColumnIds, 37 onChangeVisibleColumns, 38 canDownloadData, 39 onDownloadCsv, 40 canEditData, 41 newHref, 42}: Props<ColId>) { 43 return ( 44 <div className="md: flex flex-wrap items-center justify-between gap-3 md:flex-nowrap"> 45 <Input 46 name="filter-q" 47 value={q} 48 onChange={(e) => onChangeQ(e.target.value)} 49 placeholder="キーワードで検索" 50 className="w-[270px] basis-full text-sm md:basis-auto" 51 aria-label="検索キーワード" 52 /> 53 <div className="ml-auto flex items-center gap-2"> 54 <Popover> 55 <PopoverTrigger asChild> 56 <Button 57 variant="outline" 58 className="cursor-pointer" 59 title="表示項目の変更" 60 > 61 <Columns3 className="h-4 w-4" /> 62 表示項目 63 </Button> 64 </PopoverTrigger> 65 <PopoverContent align="end" className="w-[320px] p-0"> 66 <ColumnsChecklist 67 value={visibleColumnIds} 68 onChange={(ids) => onChangeVisibleColumns(ids as ColId[])} 69 options={columnOptions} 70 footer={ 71 <PopoverPrimitive.Close asChild> 72 <Button 73 variant="ghost" 74 size="sm" 75 type="button" 76 className="cursor-pointer" 77 > 78 閉じる 79 </Button> 80 </PopoverPrimitive.Close> 81 } 82 /> 83 </PopoverContent> 84 </Popover> 85 86 {canDownloadData && onDownloadCsv && ( 87 <Button 88 variant="outline" 89 onClick={onDownloadCsv} 90 className="cursor-pointer" 91 > 92 <FileDown className="h-4 w-4" /> 93 CSV 94 </Button> 95 )} 96 97 {canEditData && newHref && ( 98 <Button asChild> 99 <Link href={newHref}>新規登録</Link> 100 </Button> 101 )} 102 </div> 103 </div> 104 ); 105}
Toolbar は 検索+表示列+CSV+新規 を一本化。
他の一覧でも props だけ替えれば同じ UI を再利用できます。
tsx
1// src/components/datagrid/datagrid-summary.tsx 2"use client"; 3 4import * as React from "react"; 5import type { DateRange } from "react-day-picker"; 6import { fmtRangeLocal, fmtRangeStable } from "@/lib/datagrid/date-io"; 7 8type Props = { 9 mounted: boolean; 10 roleText: string; // 例: "ロール: すべて" or "ロール: 管理者, 閲覧者" 11 statusText: string; // 例: "状態: すべて" 12 createdRangeISO?: { from?: string; to?: string }; 13 updatedRangeISO?: { from?: string; to?: string }; 14 createdRange?: DateRange; // mounted=true のときローカル表示に利用 15 updatedRange?: DateRange; // 同上 16 visibleColsText: string; // "表示ID, 氏名, ..." 17}; 18 19export function DatagridSummary({ 20 mounted, 21 roleText, 22 statusText, 23 createdRangeISO, 24 updatedRangeISO, 25 createdRange, 26 updatedRange, 27 visibleColsText, 28}: Props) { 29 const createdText = mounted 30 ? fmtRangeLocal(createdRange) 31 : fmtRangeStable(createdRangeISO); 32 const updatedText = mounted 33 ? fmtRangeLocal(updatedRange) 34 : fmtRangeStable(updatedRangeISO); 35 36 const summary = `${roleText} / ${statusText} / 登録: ${createdText} / 更新: ${updatedText} / 表示: ${visibleColsText}`; 37 38 return ( 39 <div 40 className="text-muted-foreground truncate text-right text-xs" 41 title={summary} 42 > 43 {summary} 44 </div> 45 ); 46}
サマリーの 安定表示ロジック(SSR/CSR) をここへ退避。DataTable 本体の記述量が一気に減ります。
tsx
1// src/components/datagrid/datagrid-pagination.tsx 2"use client"; 3 4import * as React from "react"; 5import { Button } from "@/components/ui/button"; 6import { 7 Select, 8 SelectTrigger, 9 SelectContent, 10 SelectItem, 11 SelectValue, 12} from "@/components/ui/select"; 13import type { Table } from "@tanstack/react-table"; 14 15type Props<TData> = { 16 table: Table<TData>; 17 pageSize: number; 18 onChangePageSize: (n: number) => void; 19}; 20 21export function DatagridPagination<TData>({ 22 table, 23 pageSize, 24 onChangePageSize, 25}: Props<TData>) { 26 return ( 27 <div className="flex items-center justify-between gap-3"> 28 <div className="flex items-center gap-2 text-sm"> 29 <span className="text-muted-foreground">1ページの表示件数</span> 30 <Select 31 value={String(pageSize)} 32 onValueChange={(v) => onChangePageSize(Number(v))} 33 name="page-size" 34 > 35 <SelectTrigger className="w-[88px]"> 36 <SelectValue placeholder="件数" /> 37 </SelectTrigger> 38 <SelectContent> 39 {[20, 50, 100].map((n) => ( 40 <SelectItem key={n} value={String(n)}> 41 {n}42 </SelectItem> 43 ))} 44 </SelectContent> 45 </Select> 46 </div> 47 48 <div className="flex items-center gap-2"> 49 <span className="text-muted-foreground text-sm"> 50 Page {table.getState().pagination.pageIndex + 1} /{" "} 51 {table.getPageCount() || 1} 52 </span> 53 <Button 54 variant="outline" 55 size="sm" 56 onClick={() => table.previousPage()} 57 disabled={!table.getCanPreviousPage()} 58 className="cursor-pointer" 59 > 60 前へ 61 </Button> 62 <Button 63 variant="outline" 64 size="sm" 65 onClick={() => table.nextPage()} 66 disabled={!table.getCanNextPage()} 67 className="cursor-pointer" 68 > 69 次へ 70 </Button> 71 </div> 72 </div> 73 ); 74}
ページングも独立化。DataTable 本体は「pageSize の保持先(LS)」だけ意識すればOKです。
tsx
1// src/app/(protected)/users/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"; 28 29import type { UserRow } from "./columns"; 30import type { RoleOption } from "@/components/filters/roles-checklist"; 31 32// 共通フック&ユーティリティ 33import { useDatagridQueryState } from "@/lib/datagrid/use-datagrid-query-state"; 34import { usePersistentDatagridState } from "@/lib/datagrid/use-persistent-datagrid-state"; 35import { fromDateRange, toDateRange } from "@/lib/datagrid/date-io"; 36import { buildCsv, downloadCsv, fmtDateTime } from "@/lib/datagrid/csv"; 37 38// UI 部品 39import { DatagridToolbar } from "@/components/datagrid/datagrid-toolbar"; 40import { DatagridSummary } from "@/components/datagrid/datagrid-summary"; 41import { DatagridPagination } from "@/components/datagrid/datagrid-pagination"; 42 43type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE"; 44 45type Props = { 46 columns: ColumnDef<UserRow, unknown>[]; 47 data: UserRow[]; 48 roleOptions: RoleOption[]; 49 canDownloadData?: boolean; 50 canEditData?: boolean; 51}; 52 53export default function DataTable({ 54 columns, 55 data, 56 roleOptions, 57 canDownloadData = false, 58 canEditData = false, 59}: Props) { 60 const [mounted, setMounted] = React.useState(false); 61 React.useEffect(() => setMounted(true), []); 62 63 // 列ID(actions / q 除外) 64 const allColumnIds = React.useMemo( 65 () => 66 [ 67 "displayId", 68 "name", 69 "email", 70 "roleCode", 71 "isActive", 72 "phone", 73 "remarks", 74 "createdAt", 75 "updatedAt", 76 ] as const, 77 [], 78 ); 79 type ColId = (typeof allColumnIds)[number]; 80 81 // URL同期(roles:[]=すべて、cols: 表示列もURLに保持) 82 const [queryState, setQueryState] = useDatagridQueryState( 83 "users", 84 { 85 q: "", 86 roles: [] as string[], 87 status: "ALL" as StatusFilter, 88 createdRange: undefined as { from?: string; to?: string } | undefined, 89 updatedRange: undefined as { from?: string; to?: string } | undefined, 90 cols: Array.from(allColumnIds) as ColId[], 91 }, 92 { persistKey: "users" }, 93 ); 94 95 // 役割(見做し:空配列=すべて) 96 const allRoleCodes = React.useMemo( 97 () => roleOptions.map((o) => o.value), 98 [roleOptions], 99 ); 100 const rolesForFilter = queryState.roles.length 101 ? queryState.roles 102 : allRoleCodes; 103 104 // 日付レンジ(URL⇄UI) 105 const createdRange = React.useMemo( 106 () => toDateRange(queryState.createdRange), 107 [queryState.createdRange], 108 ); 109 const updatedRange = React.useMemo( 110 () => toDateRange(queryState.updatedRange), 111 [queryState.updatedRange], 112 ); 113 114 const setQ = (v: string) => setQueryState((s) => ({ ...s, q: v })); 115 const setRoles = (next: string[]) => 116 setQueryState((s) => ({ ...s, roles: next })); 117 const setStatus = (next: StatusFilter) => 118 setQueryState((s) => ({ ...s, status: next })); 119 const setCreatedRange = (r?: DateRange) => 120 setQueryState((s) => ({ ...s, createdRange: fromDateRange(r) })); 121 const setUpdatedRange = (r?: DateRange) => 122 setQueryState((s) => ({ ...s, updatedRange: fromDateRange(r) })); 123 const setVisibleColumnIds = (ids: ColId[]) => 124 setQueryState((s) => ({ ...s, cols: ids })); 125 126 // ページサイズだけLS 127 const [persisted, setPersisted] = usePersistentDatagridState("users", { 128 pageSize: 20, 129 }); 130 131 // 並び順(ローカル) 132 const [sorting, setSorting] = React.useState<SortingState>([ 133 { id: "createdAt", desc: true }, 134 ]); 135 136 // 列ラベル 137 const columnLabels = React.useMemo( 138 () => 139 ({ 140 displayId: "表示ID", 141 name: "氏名", 142 email: "メール", 143 roleCode: "ロール", 144 isActive: "状態", 145 phone: "電話番号", 146 remarks: "備考", 147 createdAt: "登録日時", 148 updatedAt: "更新日時", 149 }) as const, 150 [], 151 ); 152 153 // 可視列 154 const effectiveVisibleColumnIds: ColId[] = mounted 155 ? (queryState.cols as ColId[]) 156 : (Array.from(allColumnIds) as ColId[]); 157 const columnVisibility = React.useMemo<VisibilityState>(() => { 158 const set = new Set(effectiveVisibleColumnIds); 159 return { 160 actions: true, 161 q: false, 162 displayId: set.has("displayId"), 163 name: set.has("name"), 164 email: set.has("email"), 165 roleCode: set.has("roleCode"), 166 isActive: set.has("isActive"), 167 phone: set.has("phone"), 168 remarks: set.has("remarks"), 169 createdAt: set.has("createdAt"), 170 updatedAt: set.has("updatedAt"), 171 }; 172 }, [effectiveVisibleColumnIds]); 173 174 // フィルタ 175 const filteredData = React.useMemo(() => { 176 const needle = queryState.q.trim().toLowerCase(); 177 const roleSet = new Set(rolesForFilter); 178 const inRange = (d: Date, r?: DateRange) => { 179 if (!r?.from && !r?.to) return true; 180 const ts = d.getTime(); 181 if (r?.from && ts < new Date(r.from).setHours(0, 0, 0, 0)) return false; 182 if (r?.to && ts > new Date(r.to).setHours(23, 59, 59, 999)) return false; 183 return true; 184 }; 185 186 return data.filter((u) => { 187 const passQ = 188 !needle || 189 `${u.displayId} ${u.name} ${u.email} ${u.phone ?? ""} ${u.remarks ?? ""}` 190 .toLowerCase() 191 .includes(needle); 192 const passRole = roleSet.has(u.roleCode); 193 const passStatus = 194 queryState.status === "ALL" || 195 (queryState.status === "ACTIVE" ? u.isActive : !u.isActive); 196 const passCreated = inRange(u.createdAt, createdRange); 197 const passUpdated = inRange(u.updatedAt, updatedRange); 198 return passQ && passRole && passStatus && passCreated && passUpdated; 199 }); 200 }, [ 201 data, 202 queryState.q, 203 queryState.status, 204 rolesForFilter, 205 createdRange, 206 updatedRange, 207 ]); 208 209 // テーブル 210 const table = useReactTable({ 211 data: filteredData, 212 columns, 213 state: { sorting, columnVisibility }, 214 onSortingChange: setSorting, 215 getCoreRowModel: getCoreRowModel(), 216 getSortedRowModel: getSortedRowModel(), 217 getPaginationRowModel: getPaginationRowModel(), 218 initialState: { pagination: { pageIndex: 0, pageSize: 20 } }, 219 meta: { 220 roleOptions, 221 roles: rolesForFilter, 222 setRoles, 223 status: queryState.status, 224 setStatus, 225 createdRange, 226 setCreatedRange, 227 updatedRange, 228 setUpdatedRange, 229 }, 230 }); 231 232 // mount後にLSのpageSizeを反映 233 React.useEffect(() => { 234 if (mounted) table.setPageSize(persisted.pageSize); 235 }, [mounted, persisted.pageSize, table]); 236 237 // CSV 出力(可視列のみ) 238 const onDownloadCsv = React.useCallback(() => { 239 const visibleLeaf = table 240 .getVisibleLeafColumns() 241 .map((c) => c.id) 242 .filter((id) => id !== "actions" && id !== "q") as ColId[]; 243 244 const headers = visibleLeaf.map((id) => columnLabels[id]); 245 246 const rows = filteredData.map((u) => 247 visibleLeaf.map((id) => { 248 switch (id) { 249 case "displayId": 250 return u.displayId; 251 case "name": 252 return u.name; 253 case "email": 254 return u.email; 255 case "roleCode": 256 return u.roleName ?? u.roleCode; 257 case "isActive": 258 return u.isActive ? "有効" : "無効"; 259 case "phone": 260 return u.phone ?? ""; 261 case "remarks": 262 return u.remarks ?? ""; 263 case "createdAt": 264 return fmtDateTime(u.createdAt); 265 case "updatedAt": 266 return fmtDateTime(u.updatedAt); 267 default: 268 return ""; 269 } 270 }), 271 ); 272 273 const csv = buildCsv(headers, rows); 274 const ts = format(new Date(), "yyyyMMdd_HHmmss", { locale: ja }); 275 downloadCsv(`users_${ts}.csv`, csv); 276 }, [filteredData, table, columnLabels]); 277 278 // サマリー用テキスト 279 const roleLabel = 280 queryState.roles.length === 0 281 ? "ロール: すべて" 282 : `ロール: ${queryState.roles 283 .map( 284 (v) => 285 new Map(roleOptions.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 = effectiveVisibleColumnIds 295 .map((id) => columnLabels[id]) 296 .join(", "); 297 298 const filteredCount = filteredData.length; 299 300 // 画面 301 return ( 302 <div className="space-y-3"> 303 <DatagridToolbar<ColId> 304 q={queryState.q} 305 onChangeQ={setQ} 306 columnOptions={allColumnIds.map((id) => ({ 307 value: id, 308 label: columnLabels[id], 309 }))} 310 visibleColumnIds={effectiveVisibleColumnIds} 311 onChangeVisibleColumns={setVisibleColumnIds} 312 canDownloadData={canDownloadData} 313 onDownloadCsv={onDownloadCsv} 314 canEditData={canEditData} 315 newHref="/users/new" 316 /> 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={roleLabel} 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 roles: [], 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 {/* テーブル */} 358 <div className="overflow-x-auto rounded-md border pb-1"> 359 <Table className="w-full" data-testid="users-table"> 360 <TableHeader className="bg-muted/60 supports-[backdrop-filter]:bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur"> 361 {table.getHeaderGroups().map((hg) => ( 362 <TableRow key={hg.id}> 363 {hg.headers.map((header) => ( 364 <TableHead 365 key={header.id} 366 style={{ width: header.column.getSize() }} 367 > 368 {header.isPlaceholder 369 ? null 370 : flexRender( 371 header.column.columnDef.header, 372 header.getContext(), 373 )} 374 </TableHead> 375 ))} 376 </TableRow> 377 ))} 378 </TableHeader> 379 <TableBody> 380 {table.getRowModel().rows.length ? ( 381 table.getRowModel().rows.map((row) => ( 382 <TableRow 383 key={row.id} 384 data-testid={`row-${(row.original as UserRow).displayId}`} 385 > 386 {row.getVisibleCells().map((cell) => ( 387 <TableCell 388 key={cell.id} 389 style={{ width: cell.column.getSize() }} 390 > 391 {flexRender( 392 cell.column.columnDef.cell, 393 cell.getContext(), 394 )} 395 </TableCell> 396 ))} 397 </TableRow> 398 )) 399 ) : ( 400 <TableRow> 401 <TableCell 402 colSpan={table.getAllColumns().length} 403 className="text-muted-foreground py-10 text-center text-sm" 404 > 405 条件に一致するユーザが見つかりませんでした。 406 </TableCell> 407 </TableRow> 408 )} 409 </TableBody> 410 </Table> 411 </div> 412 413 <DatagridPagination<UserRow> 414 table={table} 415 pageSize={table.getState().pagination.pageSize} 416 onChangePageSize={(n) => { 417 table.setPageSize(n); 418 setPersisted((p) => ({ ...p, pageSize: n })); 419 }} 420 /> 421 </div> 422 ); 423}

使い回し方(他の一覧へ)

  • 列ラベルのマップ可視列IDの初期配列だけ、その画面の列に合わせて差し替え。
  • ロール等のドメイン特有のサマリ文字列は、DatagridSummary に渡す テキスト生成だけ 変えればOK。
  • CSV は rows の生成(カラム→文字列化)だけ、その画面のデータ型に合わせて差し替え。
これで各一覧の data-table.tsx約1/3〜1/2の行数 に圧縮され、UI責務はコンポーネント化されます。
変更の影響範囲も局所化され、保守と横展開がグッと楽になります。

メリットの整理

共通フック化することで得られる利点を表にまとめます。
項目Before(個別実装)After(共通フック)
URL同期一覧ごとに useSearchParams を実装useDatagridQueryState で統一
LocalStorage各画面で key を決めて保存usePersistentDatagridState で自動化
再利用性ユーザ一覧専用任意のエンティティに再利用可能
保守性重複コードが多い1箇所修正で全画面反映
これで「フィルタ状態を共通フックとして保存・復元」できるようにしました。

5. まとめと次回予告

本記事では、ユーザの 登録・更新・一覧のDB連携 を一通り完成させました。
これにより、管理画面からユーザ情報を直接操作できるようになり、システムとして実用的な形に近づいています。

本記事で実現した内容

今回の実装で整備した要素を整理すると、以下のようになります。
区分実装内容ポイント
登録フォームユーザ新規作成(名前・メール・ロール・パスワード・有効フラグ・電話番号・備考)RHF + Zod による入力検証、パスワード自動生成機能
更新フォームユーザ編集(表示IDは読み取り専用、他フィールドは更新可能)未入力フィールドは空文字に寄せ、controlled input エラーを回避
一覧テーブルユーザ一覧のフィルタ・ソート・ページング・列カスタマイズURLクエリ + LocalStorage を組み合わせた状態保存・復元
これらを通じて、「ユーザ管理」の基本的なCRUDサイクル を一通り網羅できました。

実装の工夫ポイント

本記事では、単なるCRUD実装にとどまらず、いくつかの重要な工夫を取り入れました。
工夫の種類内容
入力値の安定化phoneremarks を空文字に寄せて controlled/uncontrolled エラーを防止
日付フィルタの一貫性SSR時はISO表示、CSR後はローカル日付表示に切り替え、Hydrationエラーを回避
フィルタ状態の共通化useDatagridQueryStateusePersistentDatagridState を導入し、URL・Storageの両面で状態復元
表示列の管理URLクエリに cols を追加し、ユーザごとにカスタマイズした列を保持
これにより、安定したUI挙動再利用可能な仕組み を確立しています。

次回の予定

次回は、ユーザ管理に続いて ロールの登録・更新・一覧のDB連携 を実装します。
あわせて、ロールテーブルの定義についても一部見直しを行い、RBACの運用に備えます。
次章のテーマは以下の通りです:
  • ロール登録フォーム(ロールコード・表示名・優先度)
  • ロール更新フォーム(既存ロールの編集)
  • ロール一覧(優先度順ソート・検索・フィルタリング)
  • テーブル定義の調整(ローカライズテーブルの追加)
これにより、ユーザ管理とロール管理を連携させた運用基盤 が完成していきます。

参考文献

今回のユーザ登録・更新・一覧のDB連携にあたり、以下の資料や公式ドキュメントを参照しました。
分類参照先内容
ライブラリReact Hook Form - Documentationフォーム制御とバリデーションの仕組み
バリデーションZod - TypeScript-first schema validationスキーマ定義と型安全な入力検証
テーブルTanStack Table高機能テーブルの構築(ソート・フィルタ・ページング)
日付処理date-fns日付のフォーマット・ローカライズ
UI コンポーネントshadcn/ui入力フォーム・ダイアログ・チェックリストのUI実装
Next.jsNext.js App Router DocsApp RouterによるルーティングとSSR/CSRの挙動
DB/PrismaPrisma DocsPrismaを用いたモデル定義・CRUD処理
この記事の執筆・編集担当
DE

松本 孝太郎

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

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