@@ -20,7 +20,6 @@ import {
2020 SelectTrigger ,
2121 SelectValue ,
2222} from '@tecnova/ui/components/select' ;
23- import { Skeleton } from '@tecnova/ui/components/skeleton' ;
2423import {
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' ;
3339import { formatJstDate } from '@tecnova/ui/lib/format' ;
40+ import { toastError , toastSuccess } from '@tecnova/ui/lib/toast' ;
41+ import { cn } from '@tecnova/ui/lib/utils' ;
3442import { type FormEvent , useCallback , useEffect , useState } from 'react' ;
43+ import { PageHeader } from '@/components/page-header' ;
3544
3645type 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