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
3 changes: 3 additions & 0 deletions apps/admin/src/app/(authed)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { MeProvider } from '@tecnova/ui/components/me-provider';
import { Toaster } from '@tecnova/ui/components/sonner';
import { AppShell } from '@/components/app-shell';

// 認証必須セクション全体のレイアウト。MeProvider が /api/me を取得し、
// AppShell が共通ヘッダーとナビを描画する。/login は別ルートグループなので
// このレイアウトは適用されない。
// CRUD のフィードバックはここに置いた Toaster でまとめて受ける。
export default function AuthedLayout({ children }: { children: React.ReactNode }) {
return (
<MeProvider>
<AppShell>{children}</AppShell>
<Toaster richColors position="top-right" />
</MeProvider>
);
}
192 changes: 105 additions & 87 deletions apps/admin/src/app/(authed)/mentors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
SelectTrigger,
SelectValue,
} from '@tecnova/ui/components/select';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import {
Table,
TableBody,
Expand All @@ -29,9 +28,19 @@ import {
TableHeader,
TableRow,
} from '@tecnova/ui/components/table';
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@tecnova/ui/components/tooltip';
import { apiJson } from '@tecnova/ui/lib/api-client';
import { formatJstDate } from '@tecnova/ui/lib/format';
import { toastError, toastSuccess } from '@tecnova/ui/lib/toast';
import { cn } from '@tecnova/ui/lib/utils';
import { type FormEvent, useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/page-header';

type State =
| { kind: 'loading' }
Expand All @@ -48,7 +57,10 @@ export default function MentorsPage() {
const data = await apiJson<MentorsListResponse>('/api/mentors');
setState({ kind: 'ok', mentors: data.mentors });
} catch (e) {
setState({ kind: 'error', message: apiErrorMessage(e) });
setState({
kind: 'error',
message: e instanceof Error ? e.message : String(e),
});
}
}, []);

Expand All @@ -70,50 +82,53 @@ export default function MentorsPage() {
}

return (
<main className="flex flex-1 flex-col gap-6 p-8">
<section className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold">メンター管理</h2>
</section>
<TooltipProvider>
<main className="flex flex-1 flex-col gap-6 p-4 md:p-8">
<PageHeader
title="管理者一覧"
description="管理画面を利用するアカウントの追加・ロール変更・無効化を行います"
/>

<CreateMentorForm onCreated={load} />
<CreateMentorForm onCreated={load} />

{state.kind === 'loading' && <Skeleton className="h-6 w-32" />}
{state.kind === 'error' && (
<Alert variant="destructive">
<AlertTitle>エラー</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}
{state.kind === 'loading' && <TableSkeleton columns={7} rows={5} />}
{state.kind === 'error' && (
<Alert variant="destructive">
<AlertTitle>読み込めませんでした</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}

{state.kind === 'ok' && (
<Card className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>メールアドレス</TableHead>
<TableHead>名前</TableHead>
<TableHead>ロール</TableHead>
<TableHead>状態</TableHead>
<TableHead>登録日</TableHead>
<TableHead>最終ログイン</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{state.mentors.length === 0 ? (
{state.kind === 'ok' && (
<Card className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableCell className="py-6 text-center text-muted-foreground" colSpan={7}>
該当データがありません
</TableCell>
<TableHead>メールアドレス</TableHead>
<TableHead>名前</TableHead>
<TableHead>ロール</TableHead>
<TableHead>状態</TableHead>
<TableHead>登録日</TableHead>
<TableHead>最終ログイン</TableHead>
<TableHead>操作</TableHead>
</TableRow>
) : (
state.mentors.map((m) => <MentorRow key={m.id} mentor={m} onUpdated={load} />)
)}
</TableBody>
</Table>
</Card>
)}
</main>
</TableHeader>
<TableBody>
{state.mentors.length === 0 ? (
<TableRow>
<TableCell className="py-10 text-center text-muted-foreground" colSpan={7}>
まだ管理者が登録されていません
</TableCell>
</TableRow>
) : (
state.mentors.map((m) => <MentorRow key={m.id} mentor={m} onUpdated={load} />)
)}
</TableBody>
</Table>
</Card>
)}
</main>
</TooltipProvider>
);
}

Expand All @@ -122,22 +137,21 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
const [name, setName] = useState('');
const [role, setRole] = useState<'admin' | 'mentor'>('mentor');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const body: CreateMentorRequest = { email, name, role };
await apiJson<MentorItem>('/api/mentors', { method: 'POST', body });
toastSuccess(`${name} を追加しました`);
setEmail('');
setName('');
setRole('mentor');
await onCreated();
} catch (e) {
setError(apiErrorMessage(e));
toastError(e, '管理者を追加できませんでした');
} finally {
setBusy(false);
}
Expand All @@ -147,7 +161,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
<form onSubmit={submit}>
<Card>
<CardHeader>
<CardTitle>メンター追加</CardTitle>
<CardTitle>管理者追加</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-3 md:grid-cols-[minmax(16rem,1fr)_minmax(12rem,1fr)_10rem_auto] md:items-end">
Expand Down Expand Up @@ -187,12 +201,6 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
{busy ? '送信中...' : '追加'}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>追加できませんでした</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</form>
Expand All @@ -204,7 +212,6 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () =>
const [role, setRole] = useState(mentor.role);
const [active, setActive] = useState(mentor.active);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const dirty = role !== mentor.role || active !== mentor.active;
// 自分自身のロール降格 / 無効化は禁止(最後の admin が自分を外して詰むのを避ける)
Expand All @@ -214,64 +221,75 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () =>
const save = async () => {
if (!dirty || busy) return;
setBusy(true);
setError(null);
try {
const body: UpdateMentorRequest = {};
if (role !== mentor.role) body.role = role;
if (active !== mentor.active) body.active = active;
await apiJson<MentorItem>(`/api/mentors/${mentor.id}`, { method: 'PATCH', body });
toastSuccess(`${mentor.name} を保存しました`);
await onUpdated();
} catch (e) {
setError(apiErrorMessage(e));
toastError(e, '保存できませんでした');
} finally {
setBusy(false);
}
};

// 自分自身の行の操作 UI は、Tooltip で理由を添えてグレーアウトする。
const wrapSelfReadonly = (node: React.ReactNode) =>
isSelf ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{node}</span>
</TooltipTrigger>
<TooltipContent>自分自身は変更できません</TooltipContent>
</Tooltip>
) : (
node
);

return (
<TableRow className="align-top">
<TableRow className={cn('align-top', !mentor.active && 'opacity-60')}>
<TableCell>{mentor.email}</TableCell>
<TableCell>{mentor.name}</TableCell>
<TableCell>
<Select
value={role}
onValueChange={(value) => setRole(value as 'admin' | 'mentor')}
disabled={isSelf || busy}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mentor">mentor</SelectItem>
<SelectItem value="admin">admin</SelectItem>
</SelectContent>
</Select>
{wrapSelfReadonly(
<Select
value={role}
onValueChange={(value) => setRole(value as 'admin' | 'mentor')}
disabled={isSelf || busy}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mentor">mentor</SelectItem>
<SelectItem value="admin">admin</SelectItem>
</SelectContent>
</Select>,
)}
</TableCell>
<TableCell>
<Label htmlFor={activeId} className="inline-flex">
<Checkbox
id={activeId}
checked={active}
onCheckedChange={(checked) => setActive(checked === true)}
disabled={isSelf || busy}
/>
有効
</Label>
{wrapSelfReadonly(
<Label htmlFor={activeId} className="inline-flex">
<Checkbox
id={activeId}
checked={active}
onCheckedChange={(checked) => setActive(checked === true)}
disabled={isSelf || busy}
/>
有効
</Label>,
)}
</TableCell>
<TableCell>{formatJstDate(mentor.createdAt)}</TableCell>
<TableCell>{formatJstDate(mentor.lastLoginAt)}</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-2">
{wrapSelfReadonly(
<Button type="button" size="xs" onClick={save} disabled={!dirty || busy || isSelf}>
{busy ? '保存中...' : '保存'}
</Button>
{error && (
<Alert variant="destructive" className="max-w-xs">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isSelf && <p className="text-xs text-muted-foreground">自分自身は変更不可</p>}
</div>
</Button>,
)}
</TableCell>
</TableRow>
);
Expand Down
Loading
Loading