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..68a84cf 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", @@ -34,14 +34,14 @@ export const NAVIGATION: Navigation[] = [ { component: "title", name: "System management", - privilege: "SuperAdmin", + privilege: "Owner", }, { component: "item", name: "Accounts", to: "/system/accounts", icon: "Users", - privilege: "SuperAdmin", + privilege: "Owner", }, ]; diff --git a/apps/admin/src/app/(app)/dashboard/_parts/actions.ts b/apps/admin/src/app/(app)/dashboard/_parts/actions.ts new file mode 100644 index 0000000..18a36d7 --- /dev/null +++ b/apps/admin/src/app/(app)/dashboard/_parts/actions.ts @@ -0,0 +1,16 @@ +"use server"; + +import type { RecentAccountsResponse } from "@/app/api/admin/dashboard/recent-accounts/route"; +import { serverGet } from "@/lib/server/private-api"; +import type { ServerResult } from "@/types/app"; + +// 今月追加されたアカウント一覧取得(最新10件)と総数 +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/_parts/recent-accounts.tsx b/apps/admin/src/app/(app)/dashboard/_parts/recent-accounts.tsx new file mode 100644 index 0000000..702f00a --- /dev/null +++ b/apps/admin/src/app/(app)/dashboard/_parts/recent-accounts.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; +import type { DashboardAccountSummary } from "@/app/api/admin/dashboard/recent-accounts/route"; +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: DashboardAccountSummary[]; + totalCount: number; +}; + +const getPrivilegeBadgeVariant = (privilege: string): "default" | "secondary" | "destructive" | "outline" => { + switch (privilege) { + case "Owner": + return "destructive"; + case "Admin": + return "default"; + default: + return "secondary"; + } +}; + +export const RecentAccounts = ({ accounts, totalCount }: Props) => { + return ( + + +
+ 今月追加されたメンバー + {totalCount}人 +
+
+ + {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/page.tsx b/apps/admin/src/app/(app)/dashboard/page.tsx index f0d0b01..cb021d3 100644 --- a/apps/admin/src/app/(app)/dashboard/page.tsx +++ b/apps/admin/src/app/(app)/dashboard/page.tsx @@ -1,7 +1,16 @@ -"use client"; +import { getRecentAccountsAction } from "./_parts/actions"; +import { RecentAccounts } from "./_parts/recent-accounts"; -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?.accounts ?? []) : []; + const totalCount = result.success ? (result.data?.totalCount ?? 0) : 0; + + 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/_parts/actions.ts b/apps/admin/src/app/(app)/system/accounts/[id]/edit/_parts/actions.ts new file mode 100644 index 0000000..c9bef1d --- /dev/null +++ b/apps/admin/src/app/(app)/system/accounts/[id]/edit/_parts/actions.ts @@ -0,0 +1,54 @@ +"use server"; + +import z from "zod"; +import type { AccountDetail } from "@/app/api/admin/accounts/[id]/route"; +import { serverDelete, serverGet, serverPut } from "@/lib/server/private-api"; +import type { ServerResult } from "@/types/app"; +import { type EditAccountInput, editAccountSchema } from "./lib"; + +// アカウント詳細取得 +export const getAccountAction = async (id: string): Promise> => { + try { + const result = await serverGet>(`/api/admin/accounts/${id}`); + return result; + } catch (error) { + console.error("getAccountAction error:", error); + return { success: false, error: "アカウントの取得に失敗しました" }; + } +}; + +// アカウント更新 +export const updateAccountAction = async (id: string, input: EditAccountInput): Promise => { + const parsed = editAccountSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: "入力内容が正しくありません", + fieldErrors: z.flattenError(parsed.error).fieldErrors, + }; + } + + try { + const result = await serverPut(`/api/admin/accounts/${id}`, { + name: parsed.data.name || undefined, + privilege: parsed.data.privilege, + status: parsed.data.status, + caution: parsed.data.caution || undefined, + }); + return result; + } catch (error) { + console.error("updateAccountAction error:", error); + return { success: false, error: "アカウントの更新に失敗しました" }; + } +}; + +// アカウント削除 +export const deleteAccountAction = async (id: string): Promise => { + try { + const result = await serverDelete(`/api/admin/accounts/${id}`); + return result; + } catch (error) { + console.error("deleteAccountAction error:", error); + return { success: false, error: "アカウントの削除に失敗しました" }; + } +}; diff --git a/apps/admin/src/app/(app)/system/accounts/[id]/edit/_parts/edit-account.tsx b/apps/admin/src/app/(app)/system/accounts/[id]/edit/_parts/edit-account.tsx new file mode 100644 index 0000000..bd1cb2e --- /dev/null +++ b/apps/admin/src/app/(app)/system/accounts/[id]/edit/_parts/edit-account.tsx @@ -0,0 +1,238 @@ +"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 type { AccountDetail } from "@/app/api/admin/accounts/[id]/route"; +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 { deleteAccountAction, updateAccountAction } from "./actions"; +import { type EditAccountInput, editAccountSchema } from "./lib"; + +type Props = { + account: AccountDetail; + currentUserId: string; +}; + +export const EditAccount = ({ account, currentUserId }: 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" | "Owner", + 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" && ( + ( + + 注意事項(停止理由) + +