![[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携する](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-users%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #7ユーザ管理UIをDB連携する
ユーザ一覧表示・新規登録・編集フォームをDBと連動させ、ユーザデータを操作できる形へ
初回公開日
最終更新日
0. はじめに
これまでの管理画面フォーマット開発編では、DB設計(#1)、ログイン/ログアウト(#2〜#4)、ユーザプロフィール更新(#5)、RBACによるアクセス制御(#6)と、管理画面の基盤を順に整えてきました。
本記事ではこれらを前提として、ユーザ管理UIを 実際のDB(Prisma + PostgreSQL)と接続 し、新規登録・編集・一覧表示を可能にします。
本記事ではこれらを前提として、ユーザ管理UIを 実際のDB(Prisma + PostgreSQL)と接続 し、新規登録・編集・一覧表示を可能にします。
本記事で扱う範囲
本稿の対象は「ユーザ管理」のうち 新規登録・編集・一覧表示 の3機能です。
メールアドレス変更やパスワード変更は既にプロフィール更新(#5)で実装済みのため、ここでは再度扱いません。
部署コンテキストはセッションから固定されるため、UIに部署選択は存在しません。
メールアドレス変更やパスワード変更は既にプロフィール更新(#5)で実装済みのため、ここでは再度扱いません。
部署コンテキストはセッションから固定されるため、UIに部署選択は存在しません。
機能区分 | 実装済み | 本記事で扱う |
---|---|---|
displayId自動採番 | ✅ #1 | 参照のみ |
ログイン/ログアウト | ✅ #2〜#4 | - |
ユーザプロフィール | ✅ #5 | - |
RBAC制御 | ✅ #6 | 補完的に使用 |
ユーザ新規登録 | - | ✅ |
ユーザ編集 | - | ✅ |
ユーザ一覧表示 | - | ✅ |
技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.x | スキーマ定義と実行時バリデーション |
本記事では、前回の記事 【管理画面フォーマット開発編 #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)]
6 ↓
7[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 で保存
17 ↓
18[結果を返却 → 正常なら /users へリダイレクト]
スキーマの拡張
まず、
userCreateSchema
と userUpdateSchema
に phone / 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}
フォームへのフィールド追加とパスワード生成ボタンの追加
UserForm
に PhoneField / 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">パスワード *</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のみ」だったユーザ編集ページ(
基本方針は 1章と同じく、フォームは既存の
/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 の追加(更新・論理削除)
ここでは、
どちらも セッション確認→権限確認(ADMIN & 同一部署) を通過した上で処理します。
_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) は
userUpdateSchema
の emailSchema
が担っているため、クライアント側で特別な処理は不要です。これで、ユーザ編集についても DB連携 に置き換えが完了しました。
メールアドレスは 入力直後から punycode ASCII に正規化されているため、 許可ドメイン照合・重複チェック・保存 が一貫して安定します。
次章では、 ユーザ一覧のDB連携 (ページング/フィルタ/ソート)に進みます。
次章では、 ユーザ一覧のDB連携 (ページング/フィルタ/ソート)に進みます。
3. ユーザ一覧のDB連携
この章では、UIのみ版の一覧を DB連携・権限制御・フィルタ/ソート・表示項目切替・CSV まで一気通貫で仕上げます。完成後は、実運用に耐える一覧 UX(固定ヘッダ、複合フィルタ、列表示のオン/オフ、複数ソート、CSV 連携)になります。
ゴールと完成イメージ
本章を終えると、次の要件を満たしたユーザ一覧が完成します。
項目 | できること | 実装ポイント |
---|---|---|
DB連携 | 部署内・未削除ユーザのみ取得 | SSR + Prisma |
フィルタ | ロール・状態・登録/更新日の範囲・キーワード | 列ヘッダの Popover + TableMeta |
ソート | なし→降順→昇順→なし、複数ソート(Shift/Ctrl/⌘) | SortButton |
表示項目 | 列のオン/オフ(チェックリスト) | columnVisibility と同期 |
CSV | フィルタ後×表示中の列のみを出力 | BOM付きUTF-8、Excel互換 |
a11y/UX | sticky ヘッダ、キーボード、ARIA | table-container + ボタン aria-label |
画面は、下図のようになります。

型の下ごしらえ:TableMeta
を拡張して“ヘッダUI→外側ステート”を橋渡し
列ヘッダ(Popover内)の UI から、DataTable 側の state を直接いじれるようにします。
@tanstack/table-core
の TableMeta
を拡張し、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}
thead
にsticky 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}
- 表示列のオン/オフ は
visibleColumnIds
とcolumnVisibility
を同期。 - CSV は
getVisibleLeafColumns()
を使い、 画面に見えている列順 で出力。 - 初期ソートは
createdAt desc
(登録日の新しい順)。
CSVダウンロードについて
ダウンロードは、一覧の「いま見えている状態」をそのままダウンロードするようにしています。
本実装では (1) フィルタ済みデータ を (2) 可視カラム順 で、(3) BOM 付き UTF-8 として保存します(Excel 互換)。
本実装では (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-container
の max-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.ts | URLクエリと同期するための共通フック | 新規 |
フック | src/lib/datagrid/use-persistent-datagrid-state.ts | LocalStorageに保存/復元するための共通フック | 新規 |
一覧UI | src/app/(protected)/users/data-table.tsx | 共通フックを呼び出し、状態を引き渡す | 更新(import先の切替) |
共通フックの設計方針
一覧画面のフィルタは どのエンティティでも似たような構成(検索キーワード、ロール、状態、日付レンジ、表示列など)になるため、
重複を避けるためにフックを「汎用化」します。
重複を避けるためにフックを「汎用化」します。
-
useDatagridQueryState
- URLクエリに状態を反映(例:
?q=...&status=ACTIVE&roles=admin,user
) - ブラウザの戻る/進む操作でも復元できる
- URLクエリに状態を反映(例:
-
usePersistentDatagridState
- LocalStorage に保存(例:
datagrid:users
のキーで状態を保持) - ページを再訪しても同じ状態から再開可能
- LocalStorage に保存(例:
この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}
上記フックは、指定したキー(例:
DataTable 側は、戻り値の
"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) |
---|---|---|---|
キーワード | q | queryState.q | — |
ロール | roles | queryState.roles ※空配列=「すべて」 | — |
状態(ALL/ACTIVE/INACTIVE) | status | queryState.status | — |
登録日レンジ | createdRange (DateRange ) | queryState.createdRange ({ from?: ISO, to?: ISO } をURL保存。CSR後はローカル日付表示) | — |
更新日レンジ | updatedRange (DateRange ) | queryState.updatedRange ({ from?: ISO, to?: ISO } をURL保存。CSR後はローカル日付表示) | — |
表示列(チェックリスト) | visibleColumnIds | queryState.cols (ColId[] をURL保存) | — |
ページサイズ | table.getState().pagination.pageSize | — | persisted.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,setPageSize 、table |
date-io.ts | URL⇄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 の表示ずれ対策(安定表示)もここに集約しました。
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 を再利用できます。
他の一覧でも 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実装にとどまらず、いくつかの重要な工夫を取り入れました。
工夫の種類 | 内容 |
---|---|
入力値の安定化 | phone や remarks を空文字に寄せて controlled/uncontrolled エラーを防止 |
日付フィルタの一貫性 | SSR時はISO表示、CSR後はローカル日付表示に切り替え、Hydrationエラーを回避 |
フィルタ状態の共通化 | useDatagridQueryState と usePersistentDatagridState を導入し、URL・Storageの両面で状態復元 |
表示列の管理 | URLクエリに cols を追加し、ユーザごとにカスタマイズした列を保持 |
これにより、安定したUI挙動 と 再利用可能な仕組み を確立しています。
次回の予定
次回は、ユーザ管理に続いて ロールの登録・更新・一覧のDB連携 を実装します。
あわせて、ロールテーブルの定義についても一部見直しを行い、RBACの運用に備えます。
あわせて、ロールテーブルの定義についても一部見直しを行い、RBACの運用に備えます。
次章のテーマは以下の通りです:
- ロール登録フォーム(ロールコード・表示名・優先度)
- ロール更新フォーム(既存ロールの編集)
- ロール一覧(優先度順ソート・検索・フィルタリング)
- テーブル定義の調整(ローカライズテーブルの追加)
これにより、ユーザ管理とロール管理を連携させた運用基盤 が完成していきます。
参考文献
今回のユーザ登録・更新・一覧のDB連携にあたり、以下の資料や公式ドキュメントを参照しました。
分類 | 参照先 | 内容 |
---|---|---|
ライブラリ | React Hook Form - Documentation | フォーム制御とバリデーションの仕組み |
バリデーション | Zod - TypeScript-first schema validation | スキーマ定義と型安全な入力検証 |
テーブル | TanStack Table | 高機能テーブルの構築(ソート・フィルタ・ページング) |
日付処理 | date-fns | 日付のフォーマット・ローカライズ |
UI コンポーネント | shadcn/ui | 入力フォーム・ダイアログ・チェックリストのUI実装 |
Next.js | Next.js App Router Docs | App RouterによるルーティングとSSR/CSRの挙動 |
DB/Prisma | Prisma Docs | Prismaを用いたモデル定義・CRUD処理 |
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装する
これまでメニュー表示に適用していたRBACを、各ページのアクセス制御に拡張
2025/9/23公開
![[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-rbac-guard%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #5] ユーザプロフィール更新
プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装
2025/9/21公開
![[管理画面フォーマット開発編 #5] ユーザプロフィール更新のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-profile%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示
ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築
2025/9/16公開
![[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-avatar-upload%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
2025/9/12公開
![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能
httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装
2025/9/12公開
![[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-login%2Fhero-thumbnail.jpg&w=1200&q=75)