Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 30 additions & 29 deletions apps/admin/src/app/(app)/_layout/breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<BreadcrumbType[]>([]);

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;
Expand All @@ -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 (
<BreadcrumbRoot>
Expand All @@ -83,6 +82,8 @@ export const Breadcrumb = () => {
<BreadcrumbItem>
{breadcrumb.active ? (
<BreadcrumbPage>{breadcrumb.name}</BreadcrumbPage>
) : breadcrumb.disabled ? (
<span className="transition-colors">{breadcrumb.name}</span>
) : (
<BreadcrumbLink asChild>
<Link href={breadcrumb.href}>{breadcrumb.name}</Link>
Expand Down
6 changes: 3 additions & 3 deletions apps/admin/src/app/(app)/_layout/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
},
];

Expand Down
16 changes: 16 additions & 0 deletions apps/admin/src/app/(app)/dashboard/_parts/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ServerResult<RecentAccountsResponse>> => {
try {
const result = await serverGet<ServerResult<RecentAccountsResponse>>("/api/admin/dashboard/recent-accounts");
return result;
} catch (error) {
console.error("getRecentAccountsAction error:", error);
return { success: false, error: "最近追加されたアカウントの取得に失敗しました" };
}
};
68 changes: 68 additions & 0 deletions apps/admin/src/app/(app)/dashboard/_parts/recent-accounts.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<CardTitle>今月追加されたメンバー</CardTitle>
<Badge variant="secondary">{totalCount}人</Badge>
</div>
</CardHeader>
<CardContent>
{accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">今月追加されたメンバーはいません</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>メールアドレス / 名前</TableHead>
<TableHead>権限</TableHead>
<TableHead>追加日</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<Link href={`/system/accounts/${account.id}/edit`} className="font-medium hover:underline">
{account.email}
</Link>
{account.name && <p className="text-sm text-muted-foreground">{account.name}</p>}
</TableCell>
<TableCell>
<Badge variant={getPrivilegeBadgeVariant(account.privilege)}>{account.privilege}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{dayjs(account.createdAt).tz().format("YYYY/MM/DD")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};
19 changes: 14 additions & 5 deletions apps/admin/src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-6 p-6">
<RecentAccounts accounts={accounts} totalCount={totalCount} />
</div>
);
}
6 changes: 3 additions & 3 deletions apps/admin/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 (
<Layout navigation={filteredNav} userName={session.user.name ?? ""} userEmail={session.user.email}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ServerResult<AccountDetail>> => {
try {
const result = await serverGet<ServerResult<AccountDetail>>(`/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<ServerResult> => {
const parsed = editAccountSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: "入力内容が正しくありません",
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}

try {
const result = await serverPut<ServerResult>(`/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<ServerResult> => {
try {
const result = await serverDelete<ServerResult>(`/api/admin/accounts/${id}`);
return result;
} catch (error) {
console.error("deleteAccountAction error:", error);
return { success: false, error: "アカウントの削除に失敗しました" };
}
};
Loading