Skip to content

Commit 836300b

Browse files
authored
Merge pull request #31 from ut42tech/develop
Add PageHeader and ParticipantDetailSheet components with enhanced pre-registration management
2 parents c02e58f + e876f5c commit 836300b

23 files changed

Lines changed: 1543 additions & 259 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { MeProvider } from '@tecnova/ui/components/me-provider';
2+
import { Toaster } from '@tecnova/ui/components/sonner';
23
import { AppShell } from '@/components/app-shell';
34

45
// 認証必須セクション全体のレイアウト。MeProvider が /api/me を取得し、
56
// AppShell が共通ヘッダーとナビを描画する。/login は別ルートグループなので
67
// このレイアウトは適用されない。
8+
// CRUD のフィードバックはここに置いた Toaster でまとめて受ける。
79
export default function AuthedLayout({ children }: { children: React.ReactNode }) {
810
return (
911
<MeProvider>
1012
<AppShell>{children}</AppShell>
13+
<Toaster richColors position="top-right" />
1114
</MeProvider>
1215
);
1316
}

apps/admin/src/app/(authed)/mentors/page.tsx

Lines changed: 105 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
SelectTrigger,
2121
SelectValue,
2222
} from '@tecnova/ui/components/select';
23-
import { Skeleton } from '@tecnova/ui/components/skeleton';
2423
import {
2524
Table,
2625
TableBody,
@@ -29,9 +28,19 @@ import {
2928
TableHeader,
3029
TableRow,
3130
} from '@tecnova/ui/components/table';
32-
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
31+
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
32+
import {
33+
Tooltip,
34+
TooltipContent,
35+
TooltipProvider,
36+
TooltipTrigger,
37+
} from '@tecnova/ui/components/tooltip';
38+
import { apiJson } from '@tecnova/ui/lib/api-client';
3339
import { formatJstDate } from '@tecnova/ui/lib/format';
40+
import { toastError, toastSuccess } from '@tecnova/ui/lib/toast';
41+
import { cn } from '@tecnova/ui/lib/utils';
3442
import { type FormEvent, useCallback, useEffect, useState } from 'react';
43+
import { PageHeader } from '@/components/page-header';
3544

3645
type State =
3746
| { kind: 'loading' }
@@ -48,7 +57,10 @@ export default function MentorsPage() {
4857
const data = await apiJson<MentorsListResponse>('/api/mentors');
4958
setState({ kind: 'ok', mentors: data.mentors });
5059
} catch (e) {
51-
setState({ kind: 'error', message: apiErrorMessage(e) });
60+
setState({
61+
kind: 'error',
62+
message: e instanceof Error ? e.message : String(e),
63+
});
5264
}
5365
}, []);
5466

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

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

78-
<CreateMentorForm onCreated={load} />
92+
<CreateMentorForm onCreated={load} />
7993

80-
{state.kind === 'loading' && <Skeleton className="h-6 w-32" />}
81-
{state.kind === 'error' && (
82-
<Alert variant="destructive">
83-
<AlertTitle>エラー</AlertTitle>
84-
<AlertDescription>{state.message}</AlertDescription>
85-
</Alert>
86-
)}
94+
{state.kind === 'loading' && <TableSkeleton columns={7} rows={5} />}
95+
{state.kind === 'error' && (
96+
<Alert variant="destructive">
97+
<AlertTitle>読み込めませんでした</AlertTitle>
98+
<AlertDescription>{state.message}</AlertDescription>
99+
</Alert>
100+
)}
87101

88-
{state.kind === 'ok' && (
89-
<Card className="p-0">
90-
<Table>
91-
<TableHeader>
92-
<TableRow>
93-
<TableHead>メールアドレス</TableHead>
94-
<TableHead>名前</TableHead>
95-
<TableHead>ロール</TableHead>
96-
<TableHead>状態</TableHead>
97-
<TableHead>登録日</TableHead>
98-
<TableHead>最終ログイン</TableHead>
99-
<TableHead>操作</TableHead>
100-
</TableRow>
101-
</TableHeader>
102-
<TableBody>
103-
{state.mentors.length === 0 ? (
102+
{state.kind === 'ok' && (
103+
<Card className="p-0">
104+
<Table>
105+
<TableHeader>
104106
<TableRow>
105-
<TableCell className="py-6 text-center text-muted-foreground" colSpan={7}>
106-
該当データがありません
107-
</TableCell>
107+
<TableHead>メールアドレス</TableHead>
108+
<TableHead>名前</TableHead>
109+
<TableHead>ロール</TableHead>
110+
<TableHead>状態</TableHead>
111+
<TableHead>登録日</TableHead>
112+
<TableHead>最終ログイン</TableHead>
113+
<TableHead>操作</TableHead>
108114
</TableRow>
109-
) : (
110-
state.mentors.map((m) => <MentorRow key={m.id} mentor={m} onUpdated={load} />)
111-
)}
112-
</TableBody>
113-
</Table>
114-
</Card>
115-
)}
116-
</main>
115+
</TableHeader>
116+
<TableBody>
117+
{state.mentors.length === 0 ? (
118+
<TableRow>
119+
<TableCell className="py-10 text-center text-muted-foreground" colSpan={7}>
120+
まだ管理者が登録されていません
121+
</TableCell>
122+
</TableRow>
123+
) : (
124+
state.mentors.map((m) => <MentorRow key={m.id} mentor={m} onUpdated={load} />)
125+
)}
126+
</TableBody>
127+
</Table>
128+
</Card>
129+
)}
130+
</main>
131+
</TooltipProvider>
117132
);
118133
}
119134

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

127141
const submit = async (e: FormEvent) => {
128142
e.preventDefault();
129143
if (busy) return;
130144
setBusy(true);
131-
setError(null);
132145
try {
133146
const body: CreateMentorRequest = { email, name, role };
134147
await apiJson<MentorItem>('/api/mentors', { method: 'POST', body });
148+
toastSuccess(`${name} を追加しました`);
135149
setEmail('');
136150
setName('');
137151
setRole('mentor');
138152
await onCreated();
139153
} catch (e) {
140-
setError(apiErrorMessage(e));
154+
toastError(e, '管理者を追加できませんでした');
141155
} finally {
142156
setBusy(false);
143157
}
@@ -147,7 +161,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
147161
<form onSubmit={submit}>
148162
<Card>
149163
<CardHeader>
150-
<CardTitle>メンター追加</CardTitle>
164+
<CardTitle>管理者追加</CardTitle>
151165
</CardHeader>
152166
<CardContent className="flex flex-col gap-4">
153167
<div className="grid gap-3 md:grid-cols-[minmax(16rem,1fr)_minmax(12rem,1fr)_10rem_auto] md:items-end">
@@ -187,12 +201,6 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
187201
{busy ? '送信中...' : '追加'}
188202
</Button>
189203
</div>
190-
{error && (
191-
<Alert variant="destructive">
192-
<AlertTitle>追加できませんでした</AlertTitle>
193-
<AlertDescription>{error}</AlertDescription>
194-
</Alert>
195-
)}
196204
</CardContent>
197205
</Card>
198206
</form>
@@ -204,7 +212,6 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () =>
204212
const [role, setRole] = useState(mentor.role);
205213
const [active, setActive] = useState(mentor.active);
206214
const [busy, setBusy] = useState(false);
207-
const [error, setError] = useState<string | null>(null);
208215

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

238+
// 自分自身の行の操作 UI は、Tooltip で理由を添えてグレーアウトする。
239+
const wrapSelfReadonly = (node: React.ReactNode) =>
240+
isSelf ? (
241+
<Tooltip>
242+
<TooltipTrigger asChild>
243+
<span className="inline-flex">{node}</span>
244+
</TooltipTrigger>
245+
<TooltipContent>自分自身は変更できません</TooltipContent>
246+
</Tooltip>
247+
) : (
248+
node
249+
);
250+
231251
return (
232-
<TableRow className="align-top">
252+
<TableRow className={cn('align-top', !mentor.active && 'opacity-60')}>
233253
<TableCell>{mentor.email}</TableCell>
234254
<TableCell>{mentor.name}</TableCell>
235255
<TableCell>
236-
<Select
237-
value={role}
238-
onValueChange={(value) => setRole(value as 'admin' | 'mentor')}
239-
disabled={isSelf || busy}
240-
>
241-
<SelectTrigger size="sm" className="w-28">
242-
<SelectValue />
243-
</SelectTrigger>
244-
<SelectContent>
245-
<SelectItem value="mentor">mentor</SelectItem>
246-
<SelectItem value="admin">admin</SelectItem>
247-
</SelectContent>
248-
</Select>
256+
{wrapSelfReadonly(
257+
<Select
258+
value={role}
259+
onValueChange={(value) => setRole(value as 'admin' | 'mentor')}
260+
disabled={isSelf || busy}
261+
>
262+
<SelectTrigger size="sm" className="w-28">
263+
<SelectValue />
264+
</SelectTrigger>
265+
<SelectContent>
266+
<SelectItem value="mentor">mentor</SelectItem>
267+
<SelectItem value="admin">admin</SelectItem>
268+
</SelectContent>
269+
</Select>,
270+
)}
249271
</TableCell>
250272
<TableCell>
251-
<Label htmlFor={activeId} className="inline-flex">
252-
<Checkbox
253-
id={activeId}
254-
checked={active}
255-
onCheckedChange={(checked) => setActive(checked === true)}
256-
disabled={isSelf || busy}
257-
/>
258-
有効
259-
</Label>
273+
{wrapSelfReadonly(
274+
<Label htmlFor={activeId} className="inline-flex">
275+
<Checkbox
276+
id={activeId}
277+
checked={active}
278+
onCheckedChange={(checked) => setActive(checked === true)}
279+
disabled={isSelf || busy}
280+
/>
281+
有効
282+
</Label>,
283+
)}
260284
</TableCell>
261285
<TableCell>{formatJstDate(mentor.createdAt)}</TableCell>
262286
<TableCell>{formatJstDate(mentor.lastLoginAt)}</TableCell>
263287
<TableCell>
264-
<div className="flex flex-col items-start gap-2">
288+
{wrapSelfReadonly(
265289
<Button type="button" size="xs" onClick={save} disabled={!dirty || busy || isSelf}>
266290
{busy ? '保存中...' : '保存'}
267-
</Button>
268-
{error && (
269-
<Alert variant="destructive" className="max-w-xs">
270-
<AlertDescription>{error}</AlertDescription>
271-
</Alert>
272-
)}
273-
{isSelf && <p className="text-xs text-muted-foreground">自分自身は変更不可</p>}
274-
</div>
291+
</Button>,
292+
)}
275293
</TableCell>
276294
</TableRow>
277295
);

0 commit comments

Comments
 (0)