From a9f7e95a3f5e7e49f5ca16cff352dcb8bb2bd4b7 Mon Sep 17 00:00:00 2001 From: seri Date: Thu, 5 Mar 2026 12:35:47 +0900 Subject: [PATCH 01/11] wip init From dd1b00d5551324ca53c6d3f668c96118402ddd49 Mon Sep 17 00:00:00 2001 From: seri Date: Thu, 5 Mar 2026 14:29:04 +0900 Subject: [PATCH 02/11] feat: implement new account creation and management features --- apps/admin/package.json | 4 +- .../_layout/{index.tsx => app-layout.tsx} | 0 .../src/app/(app)/_layout/breadcrumb.tsx | 59 ++--- .../admin/src/app/(app)/_layout/navigation.ts | 2 +- .../dashboard/_components/recent-accounts.tsx | 67 +++++ .../src/app/(app)/dashboard/_lib/actions.ts | 16 ++ apps/admin/src/app/(app)/dashboard/page.tsx | 18 +- apps/admin/src/app/(app)/layout.tsx | 6 +- .../[id]/edit/_components/edit-account.tsx | 237 ++++++++++++++++++ .../system/accounts/[id]/edit/_lib/actions.ts | 54 ++++ .../system/accounts/[id]/edit/_lib/schemas.ts | 11 + .../system/accounts/[id]/edit/layout.tsx | 10 + .../(app)/system/accounts/[id]/edit/page.tsx | 20 ++ .../accounts/_components/account-list.tsx | 147 +++++++++++ .../account-list/account-list.module.scss | 9 - .../_components/account-list/account-list.tsx | 58 ----- .../condition/condition.module.scss | 23 -- .../account-list/condition/condition.tsx | 30 --- .../account-list/condition/index.ts | 1 - .../_components/account-list/index.ts | 1 - .../accounts/_components/account-search.tsx | 57 +++++ .../_components/delete-account-dialog.tsx | 42 ++++ .../system/accounts/_components/types.ts | 10 - .../app/(app)/system/accounts/_lib/actions.ts | 44 ++++ .../{_components => _lib}/constants.ts | 0 .../accounts/{_components => _lib}/schemas.ts | 0 .../app/(app)/system/accounts/_lib/types.ts | 13 + .../create-account/create-account.module.scss | 4 + .../create-account/create-account.tsx | 164 ++++++++++++ .../new/_components/create-account/index.ts | 1 + .../accounts/new/_components/schemas.ts | 12 - .../(app)/system/accounts/new/_lib/actions.ts | 31 +++ .../(app)/system/accounts/new/_lib/schemas.ts | 20 ++ .../app/(app)/system/accounts/new/layout.tsx | 10 + .../app/(app)/system/accounts/new/page.tsx | 5 + .../src/app/(app)/system/accounts/page.tsx | 21 +- .../src/app/api/admin/accounts/[id]/route.ts | 90 +++++++ .../admin/src/app/api/admin/accounts/route.ts | 86 +++++++ .../admin/dashboard/recent-accounts/route.ts | 38 +++ apps/admin/src/lib/server/private-api.ts | 2 + apps/admin/src/server/domains/account.ts | 2 +- apps/admin/src/styles/globals.css | 39 +-- yarn.lock | 25 +- 43 files changed, 1256 insertions(+), 233 deletions(-) rename apps/admin/src/app/(app)/_layout/{index.tsx => app-layout.tsx} (100%) create mode 100644 apps/admin/src/app/(app)/dashboard/_components/recent-accounts.tsx create mode 100644 apps/admin/src/app/(app)/dashboard/_lib/actions.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/[id]/edit/_components/edit-account.tsx create mode 100644 apps/admin/src/app/(app)/system/accounts/[id]/edit/_lib/actions.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/[id]/edit/_lib/schemas.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/[id]/edit/layout.tsx create mode 100644 apps/admin/src/app/(app)/system/accounts/[id]/edit/page.tsx create mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list.tsx delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list/account-list.module.scss delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list/account-list.tsx delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/condition.module.scss delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/condition.tsx delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/index.ts delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-list/index.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/_components/account-search.tsx create mode 100644 apps/admin/src/app/(app)/system/accounts/_components/delete-account-dialog.tsx delete mode 100644 apps/admin/src/app/(app)/system/accounts/_components/types.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/_lib/actions.ts rename apps/admin/src/app/(app)/system/accounts/{_components => _lib}/constants.ts (100%) rename apps/admin/src/app/(app)/system/accounts/{_components => _lib}/schemas.ts (100%) create mode 100644 apps/admin/src/app/(app)/system/accounts/_lib/types.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/new/_components/create-account/create-account.module.scss create mode 100644 apps/admin/src/app/(app)/system/accounts/new/_components/create-account/create-account.tsx create mode 100644 apps/admin/src/app/(app)/system/accounts/new/_components/create-account/index.ts delete mode 100644 apps/admin/src/app/(app)/system/accounts/new/_components/schemas.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/new/_lib/actions.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/new/_lib/schemas.ts create mode 100644 apps/admin/src/app/(app)/system/accounts/new/layout.tsx create mode 100644 apps/admin/src/app/(app)/system/accounts/new/page.tsx create mode 100644 apps/admin/src/app/api/admin/accounts/[id]/route.ts create mode 100644 apps/admin/src/app/api/admin/accounts/route.ts create mode 100644 apps/admin/src/app/api/admin/dashboard/recent-accounts/route.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index b1c8d6b..2c6b6e7 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -116,12 +116,12 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-tailwindcss": "3.18.2", - "globals": "17.3.0", + "globals": "17.4.0", "import-sort-parser-typescript": "6.0.0", "msw": "2.12.10", "npm-check-updates": "19.6.3", "pino-pretty": "13.1.3", - "postcss": "8.5.6", + "postcss": "8.5.8", "prettier": "3.8.1", "prettier-plugin-tailwindcss": "0.7.2", "shadcn": "3.8.5", diff --git a/apps/admin/src/app/(app)/_layout/index.tsx b/apps/admin/src/app/(app)/_layout/app-layout.tsx similarity index 100% rename from apps/admin/src/app/(app)/_layout/index.tsx rename to apps/admin/src/app/(app)/_layout/app-layout.tsx diff --git a/apps/admin/src/app/(app)/_layout/breadcrumb.tsx b/apps/admin/src/app/(app)/_layout/breadcrumb.tsx index 855a78f..89aaa2f 100644 --- a/apps/admin/src/app/(app)/_layout/breadcrumb.tsx +++ b/apps/admin/src/app/(app)/_layout/breadcrumb.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useMemo } from "react"; import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; +import { BREADCRUMB_ROUTES } from "@/app/(app)/_layout/navigation"; import { BreadcrumbItem, BreadcrumbLink, @@ -11,46 +12,52 @@ import { Breadcrumb as BreadcrumbRoot, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { BREADCRUMB_ROUTES } from "@/app/(app)/_layout/navigation"; type BreadcrumbType = { name: string; href: string; active: boolean; + disabled: boolean; +}; + +// ルートパスの [xxx] を正規表現のキャプチャグループに変換する +const pathToRegex = (routePath: string): RegExp => { + const pattern = routePath.replace(/\[([^\]]+)\]/g, "([^/]+)"); + return new RegExp(`^${pattern}$`); }; export const Breadcrumb = () => { const pathname = usePathname(); - const searchParams = useSearchParams(); - const [breadcrumbs, setBreadcrumbs] = useState([]); const getBreadcrumbs = (location: string): BreadcrumbType[] => { const results: BreadcrumbType[] = []; location.split("/").reduce((prev, curr, index, array) => { const currentPath = `${prev}/${curr}`; - const route = BREADCRUMB_ROUTES.find((route) => route.path === currentPath); + // [id] のような動的セグメントを含むルートもマッチできるよう正規表現で比較する + const route = BREADCRUMB_ROUTES.find((route) => pathToRegex(route.path).test(currentPath)); - const regexp = /\[(.*?)\]/g; // ex:[id] - let realpath = route?.path || ""; - const param = realpath.match(regexp); - if (param) { - const regexp2 = /\[(.*?)\]/; // ex:id - for (const bracketValue of param) { - const id = regexp2.exec(bracketValue)![1]; - const idValue = searchParams.get(id); - if (idValue) { - realpath = realpath.replace(bracketValue, idValue); + // route.path の動的セグメント [xxx] を currentPath の実際の値で置換してリンクを生成する + let realpath = currentPath; + if (route?.path) { + const routeSegments = route.path.split("/"); + const currentSegments = currentPath.split("/"); + realpath = route.path; + routeSegments.forEach((seg, i) => { + if (/\[.*?\]/.test(seg)) { + realpath = realpath.replace(seg, currentSegments[i]); } - } + }); } if (route?.name) { - const active: boolean = route?.disable || index + 1 === array.length ? true : false; + const disabled: boolean = route.disable === true; + const active: boolean = !disabled && index + 1 === array.length; results.push({ - href: realpath || currentPath, + href: realpath, name: route.name, - active: active, + active, + disabled, }); } return currentPath; @@ -59,15 +66,7 @@ export const Breadcrumb = () => { return results; }; - useEffect(() => { - const newBreadcrumbs = getBreadcrumbs(pathname); - setBreadcrumbs(newBreadcrumbs); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathname]); - - if (!breadcrumbs) { - return null; - } + const breadcrumbs = useMemo(() => getBreadcrumbs(pathname), [pathname]); return ( @@ -83,6 +82,8 @@ export const Breadcrumb = () => { {breadcrumb.active ? ( {breadcrumb.name} + ) : breadcrumb.disabled ? ( + {breadcrumb.name} ) : ( {breadcrumb.name} diff --git a/apps/admin/src/app/(app)/_layout/navigation.ts b/apps/admin/src/app/(app)/_layout/navigation.ts index 253088f..6c19b92 100644 --- a/apps/admin/src/app/(app)/_layout/navigation.ts +++ b/apps/admin/src/app/(app)/_layout/navigation.ts @@ -18,7 +18,7 @@ export const filterNavigationByPrivilege = (nav: Navigation[], userPrivilege: st return nav.filter((item) => !item.privilege || item.privilege === userPrivilege); }; -export const NAVIGATION: Navigation[] = [ +export const NAVIGATIONS: Navigation[] = [ { component: "item", name: "Dashboard", diff --git a/apps/admin/src/app/(app)/dashboard/_components/recent-accounts.tsx b/apps/admin/src/app/(app)/dashboard/_components/recent-accounts.tsx new file mode 100644 index 0000000..10a3cbd --- /dev/null +++ b/apps/admin/src/app/(app)/dashboard/_components/recent-accounts.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; +import type { AccountSummary } from "@/app/(app)/system/accounts/_lib/types"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import dayjs from "@/lib/utils/date"; + +type Props = { + accounts: AccountSummary[]; +}; + +const getPrivilegeBadgeVariant = (privilege: string): "default" | "secondary" | "destructive" | "outline" => { + switch (privilege) { + case "SuperAdmin": + return "destructive"; + case "Admin": + return "default"; + default: + return "secondary"; + } +}; + +export const RecentAccounts = ({ accounts }: Props) => { + return ( + + +
+ 今月追加されたメンバー + {accounts.length}人 +
+
+ + {accounts.length === 0 ? ( +

今月追加されたメンバーはいません

+ ) : ( + + + + メールアドレス / 名前 + 権限 + 追加日 + + + + {accounts.map((account) => ( + + + + {account.email} + + {account.name &&

{account.name}

} +
+ + {account.privilege} + + + {dayjs(account.createdAt).tz().format("YYYY/MM/DD")} + +
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/admin/src/app/(app)/dashboard/_lib/actions.ts b/apps/admin/src/app/(app)/dashboard/_lib/actions.ts new file mode 100644 index 0000000..5de220a --- /dev/null +++ b/apps/admin/src/app/(app)/dashboard/_lib/actions.ts @@ -0,0 +1,16 @@ +"use server"; + +import type { AccountSummary } from "@/app/(app)/system/accounts/_lib/types"; +import { serverGet } from "@/lib/server/private-api"; +import type { ServerResult } from "@/types/app"; + +// 今月追加されたアカウント一覧取得 +export const getRecentAccountsAction = async (): Promise> => { + try { + const result = await serverGet>("/api/admin/dashboard/recent-accounts"); + return result; + } catch (error) { + console.error("getRecentAccountsAction error:", error); + return { success: false, error: "最近追加されたアカウントの取得に失敗しました" }; + } +}; diff --git a/apps/admin/src/app/(app)/dashboard/page.tsx b/apps/admin/src/app/(app)/dashboard/page.tsx index f0d0b01..a11179d 100644 --- a/apps/admin/src/app/(app)/dashboard/page.tsx +++ b/apps/admin/src/app/(app)/dashboard/page.tsx @@ -1,7 +1,15 @@ -"use client"; +import { RecentAccounts } from "./_components/recent-accounts"; +import { getRecentAccountsAction } from "./_lib/actions"; -const DashboardPage = () => { - return <>todo; -}; +export const dynamic = "force-dynamic"; -export default DashboardPage; +export default async function DashboardPage() { + const result = await getRecentAccountsAction(); + const accounts = result.success ? (result.data ?? []) : []; + + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/app/(app)/layout.tsx b/apps/admin/src/app/(app)/layout.tsx index 143a5b6..36dce94 100644 --- a/apps/admin/src/app/(app)/layout.tsx +++ b/apps/admin/src/app/(app)/layout.tsx @@ -1,8 +1,8 @@ import React from "react"; import { ACCOUNT_STATUS } from "@/constants/application"; import { getAppSession } from "@/lib/auth"; -import { AppLayout as Layout } from "./_layout"; -import { NAVIGATION, filterNavigationByPrivilege } from "./_layout/navigation"; +import { AppLayout as Layout } from "./_layout/app-layout"; +import { NAVIGATIONS, filterNavigationByPrivilege } from "./_layout/navigation"; export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await getAppSession(); @@ -11,7 +11,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod return null; } - const filteredNav = filterNavigationByPrivilege(NAVIGATION, session.user.privilege); + const filteredNav = filterNavigationByPrivilege(NAVIGATIONS, session.user.privilege); return ( diff --git a/apps/admin/src/app/(app)/system/accounts/[id]/edit/_components/edit-account.tsx b/apps/admin/src/app/(app)/system/accounts/[id]/edit/_components/edit-account.tsx new file mode 100644 index 0000000..df474f2 --- /dev/null +++ b/apps/admin/src/app/(app)/system/accounts/[id]/edit/_components/edit-account.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; +import { TimestampField } from "@/components/timestamp-field"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { AccountDetail } from "../../../_lib/types"; +import { deleteAccountAction, updateAccountAction } from "../_lib/actions"; +import { type EditAccountInput, editAccountSchema } from "../_lib/schemas"; + +type Props = { + account: AccountDetail; +}; + +export const EditAccount = ({ account }: Props) => { + const router = useRouter(); + const [errorMessage, setErrorMessage] = useState(""); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const form = useForm({ + resolver: zodResolver(editAccountSchema), + mode: "onBlur", + defaultValues: { + name: account.name ?? "", + privilege: account.privilege as "Normal" | "Admin" | "SuperAdmin", + status: account.status as "active" | "inactive" | "suspended", + caution: account.caution ?? "", + }, + }); + const { isSubmitting } = form.formState; + const watchStatus = form.watch("status"); + + const onSubmit = async (data: EditAccountInput) => { + setErrorMessage(""); + try { + const result = await updateAccountAction(account.id, data); + if (result.success) { + toast.success("アカウントを更新しました"); + router.push("/system/accounts"); + } else { + setErrorMessage(result.error || ""); + if (result.fieldErrors) { + Object.entries(result.fieldErrors).forEach(([field, errors]) => { + form.setError(field as keyof EditAccountInput, { message: errors[0] }); + }); + } + } + } catch (e) { + console.error(e); + setErrorMessage("アカウントの更新に失敗しました"); + } + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const result = await deleteAccountAction(account.id); + if (result.success) { + toast.success("アカウントを削除しました"); + router.push("/system/accounts"); + } else { + toast.error(result.error || "削除に失敗しました"); + setShowDeleteDialog(false); + } + } finally { + setIsDeleting(false); + } + }; + + return ( + <> + + + アカウント編集 + + +
+ + {/* メールアドレス(読み取り専用) */} +
+ + +
+ + ( + + 名前 + + + + + + )} + /> + + ( + + + 権限 + * + + + + + )} + /> + + ( + + + ステータス + * + + + + + )} + /> + + {watchStatus === "suspended" && ( + ( + + 注意事項(停止理由) + +