diff --git a/apps/admin/src/app/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 91cc535..426478e 100644 --- a/apps/admin/src/app/(authed)/mentors/page.tsx +++ b/apps/admin/src/app/(authed)/mentors/page.tsx @@ -10,6 +10,8 @@ import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/aler import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; import { Checkbox } from '@tecnova/ui/components/checkbox'; +import { DataError } from '@tecnova/ui/components/data-error'; +import { EmptyState } from '@tecnova/ui/components/empty-state'; import { Input } from '@tecnova/ui/components/input'; import { Label } from '@tecnova/ui/components/label'; import { useMe } from '@tecnova/ui/components/me-provider'; @@ -36,41 +38,22 @@ import { TooltipProvider, TooltipTrigger, } from '@tecnova/ui/components/tooltip'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; 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 { type FormEvent, useState } from 'react'; import { PageHeader } from '@/components/page-header'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; -type State = - | { kind: 'loading' } - | { kind: 'ok'; mentors: MentorItem[] } - | { kind: 'error'; message: string }; - export default function MentorsPage() { const me = useMe(); - const [state, setState] = useState({ kind: 'loading' }); - - const load = useCallback(async () => { - setState({ kind: 'loading' }); - try { - const data = await apiJson('/api/mentors'); - setState({ kind: 'ok', mentors: data.mentors }); - } catch (e) { - setState({ - kind: 'error', - message: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - useEffect(() => { - if (me.mentor.role !== 'admin') return; - void load(); - }, [me.mentor.role, load]); + // admin のときだけ取得する。作成/更新後は reload() で再取得。 + const { state, reload } = useApiResource('/api/mentors', { + enabled: me.mentor.role === 'admin', + }); // ガード: ナビには非表示だが、URL 直叩き対策。/api/mentors も 403 で弾かれる。 if (me.mentor.role !== 'admin') { @@ -95,7 +78,7 @@ export default function MentorsPage() { - + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 @@ -113,18 +96,11 @@ export default function MentorsPage() { )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && - (state.mentors.length === 0 ? ( -
- まだ管理者が登録されていません -
+ (state.data.mentors.length === 0 ? ( + ) : ( <> {/* モバイル: カードリスト。 @@ -133,8 +109,8 @@ export default function MentorsPage() { 未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、 保存すれば再取得で両者が同期するため許容するトレードオフ。 */}
- {state.mentors.map((m) => ( - + {state.data.mentors.map((m) => ( + ))}
@@ -153,8 +129,8 @@ export default function MentorsPage() { - {state.mentors.map((m) => ( - + {state.data.mentors.map((m) => ( + ))} @@ -167,7 +143,7 @@ export default function MentorsPage() { ); } -function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { +function CreateMentorForm({ onCreated }: { onCreated: () => void }) { const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [role, setRole] = useState<'admin' | 'mentor'>('mentor'); @@ -184,7 +160,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { setEmail(''); setName(''); setRole('mentor'); - await onCreated(); + onCreated(); } catch (e) { toastError(e, '管理者を追加できませんでした'); } finally { @@ -248,7 +224,7 @@ function MentorRow({ variant, }: { mentor: MentorItem; - onUpdated: () => Promise; + onUpdated: () => void; // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード variant: 'row' | 'card'; }) { @@ -271,7 +247,7 @@ function MentorRow({ if (active !== mentor.active) body.active = active; await apiJson(`/api/mentors/${mentor.id}`, { method: 'PATCH', body }); toastSuccess(`${mentor.name} を保存しました`); - await onUpdated(); + onUpdated(); } catch (e) { toastError(e, '保存できませんでした'); } finally { diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index 065c54c..f80880a 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -9,10 +9,10 @@ import { } from '@tabler/icons-react'; import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas'; import { toJstDateString } from '@tecnova/shared/venue-schedule'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { DataError } from '@tecnova/ui/components/data-error'; import { Select, SelectContent, @@ -31,19 +31,14 @@ import { } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; -import { useCallback, useEffect, useState } from 'react'; +import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource'; +import { useState } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PageHeader } from '@/components/page-header'; import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; -type SessionsState = - | { kind: 'loading' } - | { kind: 'ok'; data: TodaySessionsResponse } - | { kind: 'error'; message: string }; - // セレクタで「今日」を選んでいる状態のセンチネル値。 // 空文字や undefined を使うと Select の制御値として扱いにくいのでこの形に。 const TODAY_VALUE = '__today__'; @@ -57,40 +52,19 @@ const fmtTime = (iso: string): string => }).format(new Date(iso)); export default function DashboardPage() { - const [sessions, setSessions] = useState({ kind: 'loading' }); - const [events, setEvents] = useState([]); const [selectedDate, setSelectedDate] = useState(TODAY_VALUE); const [selectedParticipantId, setSelectedParticipantId] = useState(null); - const loadSessions = useCallback(async (dateOrToday: string) => { - setSessions({ kind: 'loading' }); - try { - const path = - dateOrToday === TODAY_VALUE - ? '/api/sessions' - : `/api/sessions?date=${encodeURIComponent(dateOrToday)}`; - const data = await apiJson(path); - setSessions({ kind: 'ok', data }); - } catch (e) { - setSessions({ kind: 'error', message: apiErrorMessage(e) }); - } - }, []); - - useEffect(() => { - // イベント一覧は失敗しても致命ではないので、エラーは表示せず空で続行する。 - void (async () => { - try { - const r = await apiJson('/api/events'); - setEvents(r.events); - } catch { - setEvents([]); - } - })(); - }, []); + // 日付を path に含めることで、選択変更で自動再取得される。更新ボタンは reload()。 + const sessionsPath = + selectedDate === TODAY_VALUE + ? '/api/sessions' + : `/api/sessions?date=${encodeURIComponent(selectedDate)}`; + const sessions = useApiResource(sessionsPath); - useEffect(() => { - void loadSessions(selectedDate); - }, [selectedDate, loadSessions]); + // イベント一覧は失敗しても致命ではないので、取得できたときだけ使う(エラーは無視)。 + const eventsResource = useApiResource('/api/events'); + const events = eventsResource.state.kind === 'ok' ? eventsResource.state.data.events : []; const today = toJstDateString(new Date()); // 「本日」ラベル + イベントとして登録済みの過去日を結合する。 @@ -122,8 +96,8 @@ export default function DashboardPage() { type="button" variant="outline" size="sm" - onClick={() => loadSessions(selectedDate)} - disabled={sessions.kind === 'loading'} + onClick={() => sessions.reload()} + disabled={sessions.state.kind === 'loading'} > 更新 @@ -137,7 +111,7 @@ export default function DashboardPage() { 常時マウントなので入場は一度だけ(再フェッチで再生されない)。 */} setSelectedParticipantId(id)} /> @@ -156,10 +130,10 @@ function DashboardBody({ sessions, onSelectParticipant, }: { - sessions: SessionsState; + sessions: ResourceState; onSelectParticipant: (id: string) => void; }) { - if (sessions.kind === 'loading') { + if (sessions.kind === 'loading' || sessions.kind === 'idle') { return ( <>
@@ -180,12 +154,7 @@ function DashboardBody({ } if (sessions.kind === 'error') { - return ( - - セッションを読み込めませんでした - {sessions.message} - - ); + return ; } const { event, sessions: rows, summary } = sessions.data; diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index f1582a6..86e0e27 100644 --- a/apps/admin/src/app/(authed)/participants/page.tsx +++ b/apps/admin/src/app/(authed)/participants/page.tsx @@ -2,10 +2,11 @@ import { IconSearch, IconX } from '@tabler/icons-react'; import { GRADES, type Grade, type ParticipantsListResponse } from '@tecnova/shared/schemas'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; import { Card } from '@tecnova/ui/components/card'; +import { DataError } from '@tecnova/ui/components/data-error'; +import { EmptyState } from '@tecnova/ui/components/empty-state'; import { Input } from '@tecnova/ui/components/input'; import { Select, @@ -24,7 +25,7 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { useEffect, useState } from 'react'; import { PageHeader } from '@/components/page-header'; @@ -32,11 +33,6 @@ import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; -type State = - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipantsListResponse } - | { kind: 'error'; message: string }; - const PAGE_SIZE = 50; // 「すべて」を表すセンチネル値。SelectItem は空文字 value を受け付けない。 @@ -44,7 +40,6 @@ const ANY_GRADE = '__any_grade__'; const ANY_ACTIVE = '__any_active__'; export default function ParticipantsPage() { - const [state, setState] = useState({ kind: 'loading' }); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [grade, setGrade] = useState(ANY_GRADE); @@ -73,26 +68,15 @@ export default function ParticipantsPage() { setPage(1); }; - useEffect(() => { - void (async () => { - setState({ kind: 'loading' }); - try { - const params = new URLSearchParams({ - page: String(page), - limit: String(PAGE_SIZE), - }); - if (debouncedSearch) params.set('search', debouncedSearch); - if (grade !== ANY_GRADE) params.set('grade', grade); - if (activeFilter !== ANY_ACTIVE) params.set('active', activeFilter); - const data = await apiJson( - `/api/participants?${params.toString()}`, - ); - setState({ kind: 'ok', data }); - } catch (e) { - setState({ kind: 'error', message: apiErrorMessage(e) }); - } - })(); - }, [debouncedSearch, page, grade, activeFilter]); + // クエリを path に組み立てる。page/検索/フィルタが変わると path が変わり、 + // useApiResource が自動で再取得する。 + const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); + if (debouncedSearch) params.set('search', debouncedSearch); + if (grade !== ANY_GRADE) params.set('grade', grade); + if (activeFilter !== ANY_ACTIVE) params.set('active', activeFilter); + const { state } = useApiResource( + `/api/participants?${params.toString()}`, + ); const totalPages = state.kind === 'ok' ? Math.max(1, Math.ceil(state.data.pagination.total / PAGE_SIZE)) : 1; @@ -172,21 +156,14 @@ export default function ParticipantsPage() { )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && ( <> {/* モバイル: カードリスト */}
{state.data.participants.length === 0 ? ( -
- 該当する利用者が見つかりません -
+ ) : ( state.data.participants.map((p) => ( new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo', @@ -80,29 +74,10 @@ const todayInJst = (): string => export default function PreRegistrationsPage() { const me = useMe(); - const [state, setState] = useState({ kind: 'loading' }); - - const load = useCallback(async () => { - setState({ kind: 'loading' }); - try { - const data = await apiJson('/api/pre-registrations'); - setState({ - kind: 'ok', - preRegistrations: data.preRegistrations, - activatedPreRegistrations: data.activatedPreRegistrations ?? [], - }); - } catch (e) { - setState({ - kind: 'error', - message: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - useEffect(() => { - if (me.mentor.role !== 'admin') return; - void load(); - }, [me.mentor.role, load]); + // admin のときだけ取得する。作成/削除後は reload() で再取得。 + const { state, reload } = useApiResource('/api/pre-registrations', { + enabled: me.mentor.role === 'admin', + }); // ガード: ナビには非表示だが、URL 直叩き対策。/api/pre-registrations も 403 で弾かれる。 if (me.mentor.role !== 'admin') { @@ -126,7 +101,7 @@ export default function PreRegistrationsPage() { - + {/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。 @@ -144,28 +119,21 @@ export default function PreRegistrationsPage() {
)} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && ( <> - {state.preRegistrations.length === 0 ? ( -
- ID未発行の事前登録はありません -
+ {state.data.preRegistrations.length === 0 ? ( + ) : ( <> {/* モバイル: カードリスト */}
- {state.preRegistrations.map((p) => ( + {state.data.preRegistrations.map((p) => ( ))} @@ -185,11 +153,11 @@ export default function PreRegistrationsPage() { - {state.preRegistrations.map((p) => ( + {state.data.preRegistrations.map((p) => ( ))} @@ -199,7 +167,7 @@ export default function PreRegistrationsPage() { )} - + )} @@ -299,7 +267,7 @@ function ActivatedPreRegistrationsTable({ items }: { items: ActivatedPreRegistra ); } -function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise }) { +function CreatePreRegistrationForm({ onCreated }: { onCreated: () => void }) { const [fullName, setFullName] = useState(''); const [nickname, setNickname] = useState(''); const [grade, setGrade] = useState(''); @@ -330,7 +298,7 @@ function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise Promise; + onDeleted: () => void; // 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード variant: 'row' | 'card'; }) { @@ -433,7 +401,7 @@ function PreRegistrationRow({ throw new ApiError(r.status, body); } toastSuccess(`${item.preRegistrationId} を削除しました`); - await onDeleted(); + onDeleted(); } catch (e) { toastError(e, '削除できませんでした'); } finally { diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx index dd01c19..331bfab 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -9,9 +9,9 @@ import { IconSunset2, } from '@tabler/icons-react'; import type { ParticipationSummaryResponse } from '@tecnova/shared/schemas'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { DataError } from '@tecnova/ui/components/data-error'; import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { @@ -23,21 +23,15 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { cn } from '@tecnova/ui/lib/utils'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PageHeader } from '@/components/page-header'; import { Reveal } from '@/components/reveal'; -type SummaryState = - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipationSummaryResponse } - | { kind: 'error'; message: string }; - export default function StatsPage() { - const [summary, setSummary] = useState({ kind: 'loading' }); // 入力中の値(適用ボタンを押すまで反映しない)。空文字 = フィルタなし。 const [fromInput, setFromInput] = useState(''); const [toInput, setToInput] = useState(''); @@ -45,24 +39,14 @@ export default function StatsPage() { const [appliedFrom, setAppliedFrom] = useState(''); const [appliedTo, setAppliedTo] = useState(''); - const loadSummary = useCallback(async (from: string, to: string) => { - setSummary({ kind: 'loading' }); - try { - const params = new URLSearchParams(); - if (from) params.set('from', from); - if (to) params.set('to', to); - const query = params.toString(); - const path = query ? `/api/stats/participation?${query}` : '/api/stats/participation'; - const data = await apiJson(path); - setSummary({ kind: 'ok', data }); - } catch (e) { - setSummary({ kind: 'error', message: apiErrorMessage(e) }); - } - }, []); - - useEffect(() => { - void loadSummary(appliedFrom, appliedTo); - }, [appliedFrom, appliedTo, loadSummary]); + // 確定レンジを path に組み立てる。適用/全期間で path が変わり自動再取得される。 + const rangeParams = new URLSearchParams(); + if (appliedFrom) rangeParams.set('from', appliedFrom); + if (appliedTo) rangeParams.set('to', appliedTo); + const rangeQuery = rangeParams.toString(); + const summary = useApiResource( + rangeQuery ? `/api/stats/participation?${rangeQuery}` : '/api/stats/participation', + ); const applyFilter = () => { setAppliedFrom(fromInput); @@ -107,7 +91,7 @@ export default function StatsPage() { type="button" size="sm" onClick={applyFilter} - disabled={summary.kind === 'loading'} + disabled={summary.state.kind === 'loading'} > 適用 @@ -123,14 +107,14 @@ export default function StatsPage() { {/* StatsBody はフラグメントを返すので、main の gap-6 を保つため Reveal 側で再指定する。 */} - + ); } -function StatsBody({ summary }: { summary: SummaryState }) { - if (summary.kind === 'loading') { +function StatsBody({ summary }: { summary: ResourceState }) { + if (summary.kind === 'loading' || summary.kind === 'idle') { return ( <>
@@ -146,12 +130,7 @@ function StatsBody({ summary }: { summary: SummaryState }) { } if (summary.kind === 'error') { - return ( - - 集計を読み込めませんでした - {summary.message} - - ); + return ; } const { totals, byDate } = summary.data; diff --git a/apps/admin/src/components/participant-detail-sheet.tsx b/apps/admin/src/components/participant-detail-sheet.tsx index b8a6df2..fce9f4e 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -1,8 +1,8 @@ 'use client'; import type { ParticipantProfileResponse } from '@tecnova/shared/schemas'; -import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; +import { DataError } from '@tecnova/ui/components/data-error'; import { Sheet, SheetContent, @@ -20,21 +20,14 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TermBadge } from '@tecnova/ui/components/term-badge'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; -import { useEffect, useState } from 'react'; interface Props { participantId: string | null; onOpenChange: (open: boolean) => void; } -type State = - | { kind: 'idle' } - | { kind: 'loading' } - | { kind: 'ok'; data: ParticipantProfileResponse } - | { kind: 'error'; message: string }; - // ISO 文字列を JST の 'YYYY/MM/DD HH:mm' に整形する。空なら '—'。 const fmtDateTime = (iso: string | null): string => { if (!iso) return '—'; @@ -69,30 +62,11 @@ const fmtMinutes = (minutes: number | null): string => { }; export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) { - const [state, setState] = useState({ kind: 'idle' }); const open = participantId !== null; - - useEffect(() => { - if (!participantId) { - setState({ kind: 'idle' }); - return; - } - let cancelled = false; - setState({ kind: 'loading' }); - void (async () => { - try { - const data = await apiJson( - `/checkin/participants/${encodeURIComponent(participantId)}`, - ); - if (!cancelled) setState({ kind: 'ok', data }); - } catch (e) { - if (!cancelled) setState({ kind: 'error', message: apiErrorMessage(e) }); - } - })(); - return () => { - cancelled = true; - }; - }, [participantId]); + // 開いているあいだだけ取得する(閉じている=path=null で idle)。 + const { state } = useApiResource( + participantId ? `/checkin/participants/${encodeURIComponent(participantId)}` : null, + ); return ( @@ -135,12 +109,7 @@ export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) { )} - {state.kind === 'error' && ( - - 読み込めませんでした - {state.message} - - )} + {state.kind === 'error' && } {state.kind === 'ok' && }
diff --git a/docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md b/docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md new file mode 100644 index 0000000..d64ca49 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md @@ -0,0 +1,154 @@ +# admin データ層モダナイゼーション 設計(リサーチ結論つき) + +作成日: 2026-06-02 / 対象: `apps/admin`(一部 `packages/ui` 共有)/ Next.js 16.2.4 + React 19 + +## 0. 背景とゴール + +「Next.js のモダンなフォールバック / PPR が活かせる箇所を見直し、リファクタリングも兼ねて改善する」という依頼を受け、exa Web 検索+バージョン同梱ドキュメント(`node_modules/.pnpm/next@16.2.4`)+実コードで調査した。結論として **PPR / サーバーサイド・ストリーミングは現アーキテクチャには素直に効かない**。代わりに **クライアント取得パターンの共通化(リファクタリング)** が高価値・低リスクで、その先に段階的にサーバー取得への道がある。 + +### リサーチ結論(要点) + +- **PPR は 16.2.4 で `cacheComponents: true` に統合済み。`experimental.ppr` を設定するとハードエラー(ビルド不可)。** PPR/Cache Components は「サーバーレンダリングされる・キャッシュ可能・サーバー取得される」コンテンツの静的シェル+ストリーミングに効く。admin は全ページが `'use client'` で `useEffect`+`apiFetch` によりクロスオリジン API から取得しており、サーバー側にシェルへ畳み込めるデータが無い → **効果ゼロ・移行コストのみ**。`cacheComponents` は **有効化しない**。 +- **`loading.tsx` / Suspense は「サーバーコンポーネントが suspend したとき」しか発火しない。** `useEffect` 取得のクライアントページは描画時に suspend しないため、`loading.tsx` はナビゲーション直後に一瞬出て、API 取得完了前に消える。さらに `MeProvider` の認証ゲート(クライアント側 `/api/me` 待ち)に覆い隠される。現状のページ内スケルトン状態機械が正しい。 +- **サーバー取得は現状不可能。** Better Auth のセッション Cookie は **host-only**(`apps/api/src/lib/auth.ts` に `advanced.crossSubDomainCookies` 等の設定なし)で、API オリジン(`tecnova-api.sz-lab.jp` / `:8787`)にのみ発行される。admin オリジンの Next.js サーバーは `cookies()` でセッションを読めず、API へサーバー取得できない。解放には BFF プロキシ or クロスサブドメイン Cookie が必要(別サブプロジェクト)。 +- **真の高価値リファクタ=重複の共通化。** `loading|ok|error` の取得状態機械が **6 箇所**(5 ページ+詳細シート)でほぼ同形に重複。エラー用 `Alert`・空状態ブロックもページごとに再実装。レンダリング時クラッシュを拾う error boundary も無い。 + +### ゴール + +クライアント取得アーキテクチャを維持したまま、(1) 取得/ローディング/エラーの重複を共通プリミティブへ集約し、(2) 体感速度と耐障害性を上げ、(3) 将来のサーバー取得への移行口を用意する。**段階的に・低リスク順に**進める。 + +--- + +## 1. サブプロジェクト分割(この順に実装) + +| # | 名称 | 主眼 | リスク | 依存 | +|---|------|------|--------|------| +| 1 | データ層の共通化 | `useApiResource` フック+`DataError`/`EmptyState`。6 箇所の重複を解消 | 低(挙動・UI 不変) | なし | +| 2 | 耐障害性+即時シェル | `(authed)/error.tsx`、`MeProvider` 即時シェル化、各ルート `loading.tsx`+ページスケルトン抽出 | 中(共有 `MeProvider` に波及) | 1 | +| 3 | サーバー取得 | BFF or クロスサブドメイン Cookie → 閲覧系をサーバー取得+Suspense ストリーミング | 高(認証・セキュリティ・dev 環境) | 1, 2 | + +各サブプロジェクトは独立した spec → plan → 実装サイクルを持つ。本書はサブプロジェクト 1 を詳細化し、2・3 は概要と未決事項を記す。 + +--- + +## 2. サブプロジェクト 1(詳細): データ層の共通化 + +### 2.1 新規プリミティブ(`packages/ui`) + +#### `packages/ui/src/lib/use-api-resource.ts`(`'use client'`) + +```ts +export type ResourceState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ok'; data: T } + | { kind: 'error'; message: string }; + +export interface UseApiResourceResult { + state: ResourceState; + reload: () => void; +} + +// path が null か enabled=false のとき idle(取得しない)。 +// path が変わると自動で再取得する(クエリ文字列を path に含めることで +// 検索・フィルタ・ページング・日付変更の再取得を表現する)。 +// reload() は手動再取得(更新ボタン・ミューテーション後の再読込)。 +export function useApiResource( + path: string | null, + options?: { enabled?: boolean }, +): UseApiResourceResult; +``` + +実装方針: +- 内部は `useState>` + `reloadKey`(`reload()` で increment)。 +- `useEffect`(依存 `[path, enabled, reloadKey]`)で: `!path || enabled===false` → `idle` にして return。それ以外は `loading` にしてから `apiJson(path)` を呼ぶ。`participant-detail-sheet.tsx` 既存の **cancelled フラグ** によるアンマウント/パラメータ変更時の競合防止を踏襲。 +- エラー文言は既存の正規ヘルパ `apiErrorMessage(e)` を使う(文言生成も統一)。 +- `apiJson` は 204 を扱わない(JSON 前提)。削除など 204 を返す系は従来どおりページ側の `apiFetch` を使う(フックの対象外=ミューテーションは扱わない、読み取り専用フック)。 + +#### `packages/ui/src/components/data-error.tsx` + +```tsx +// 取得失敗時の destructive Alert(任意で再試行ボタン)。 +export function DataError({ + title = '読み込めませんでした', + message, + onRetry, +}: { title?: string; message: string; onRetry?: () => void }): JSX.Element; +``` + +#### `packages/ui/src/components/empty-state.tsx` + +```tsx +// 中央寄せの空状態ブロック(rounded-2xl border bg-card)。任意でアイコン。 +export function EmptyState({ + icon: Icon, + message, + className, +}: { icon?: ComponentType<{ className?: string }>; message: string; className?: string }): JSX.Element; +``` + +いずれも `packages/ui` の barrel/慣例に合わせてエクスポート。`apps/admin/next.config.ts` の `transpilePackages` は既に `@tecnova/ui` を含むため追加設定不要。 + +### 2.2 移行(admin 6 箇所) + +| 箇所 | path | enabled | reload 用途 | +|------|------|---------|-------------| +| dashboard `page.tsx` | `selectedDate===TODAY ? '/api/sessions' : '/api/sessions?date=…'` + 別途 `/api/events`(best-effort) | 常時 | 更新ボタン | +| participants | `/api/participants?page=&limit=&search=&grade=&active=`(debounced) | 常時 | — | +| stats | `/api/stats/participation?from=&to=`(適用済みレンジ) | 常時 | — | +| mentors | `/api/mentors` | `me.mentor.role==='admin'` | 作成/更新後 | +| pre-registrations | `/api/pre-registrations` | `me.mentor.role==='admin'` | 作成/削除後 | +| `participant-detail-sheet.tsx` | `participantId ? '/checkin/participants/:id' : null` | — | — | + +- フックは常に無条件で呼ぶ(hooks ルール)。ロールゲートの早期 return(アクセス権限 Alert)はフック呼び出しの **後** に置く(現状と同じ並び)。 +- 各ページの `type State`・`useEffect`・`useCallback(load)` を撤去し、`useApiResource` の `state`/`reload` に置換。 +- エラー表示を ``、空状態を `` に置換。 +- `events` は best-effort なので別の `useApiResource('/api/events')` を使い、`ok` のときだけ `events` を読む(エラーは無視)。 + +### 2.3 ファイル構成 + +- 新規: `packages/ui/src/lib/use-api-resource.ts`, `packages/ui/src/components/data-error.tsx`, `packages/ui/src/components/empty-state.tsx` +- 変更: `apps/admin/src/app/(authed)/page.tsx`, `participants/page.tsx`, `stats/page.tsx`, `mentors/page.tsx`, `pre-registrations/page.tsx`, `apps/admin/src/components/participant-detail-sheet.tsx` + +### 2.4 非ゴール / 不変条件 + +- UI・挙動・エンドポイント・debounce・ページング・ロールゲートは **不変**(純粋な内部リファクタ)。 +- ミューテーション(POST/PATCH/DELETE)はフック化しない(読み取り専用)。 +- `cacheComponents`/PPR は有効化しない。 +- checkin/signage は本サブプロジェクトでは変更しない(フックは将来 checkin にも転用可能だが対象外)。 + +### 2.5 検証 + +- `pnpm --filter admin --filter @tecnova/ui type-check` と `pnpm biome check apps/admin/src packages/ui/src` が green。 +- Playwright(`/api/me`+各データをモック)で 5 ページ+詳細シートが従来どおり描画・検索・フィルタ・ページング・更新・ミューテーション後再読込・エラー/空状態表示することを確認(デスクトップ/モバイル)。 +- 重複していた `type State` / `useEffect` 取得ロジックが各ページから消えていること。 + +--- + +## 3. サブプロジェクト 2(概要): 耐障害性+即時シェル + +- `apps/admin/src/app/(authed)/error.tsx`(`'use client'` 必須): レンダリング時クラッシュの安全網。`DataError` を再利用。ルート直下にも最小の `error.tsx` を検討。 +- `MeProvider` 即時シェル化: 現状は `/api/me` 解決までツリー全体をスケルトンで止める。`AppShell` のクローム(サイドバー/ナビ)を即描画し、**ページ本文だけ**を認証待ちにする。`packages/ui` 共有のため checkin/signage への影響を要確認(オプトイン or 後方互換に注意)。 +- ページスケルトン抽出(`ListPageSkeleton` 等)→ ページ内ローディングと将来の `loading.tsx` の両方を 1 コンポーネントで賄う。 +- `loading.tsx` は「ナビゲーション/コールド JS の窓」だけに効く点を理解した上で、即時シェル化後に追加(それ以前は `MeProvider` に隠れて無価値)。 +- 任意: ミューテーションフォームに React 19 `useOptimistic`。 + +未決: `MeProvider` を共有のまま変えるか、admin 専用ラッパに切り出すか。 + +--- + +## 4. サブプロジェクト 3(概要・別設計): サーバー取得 + +**前提となる認証アーキテクチャの決定(要ブレスト):** + +- **案A: 同一オリジン BFF プロキシ** — admin オリジンに Route Handler/リライトを置き `/api/*` を Workers API へ転送。Cookie を host-only のまま admin 同一オリジン化でき dev でも動く(`localhost:3001/api → :8787`)。実装量は多め(認証フローも admin 経由になり得る)。 +- **案B: クロスサブドメイン Cookie** — `apps/api/src/lib/auth.ts` に `advanced.crossSubDomainCookies = { enabled: true, domain: 'sz-lab.jp' }` + `defaultCookieAttributes = { sameSite: 'none', secure: true }`。admin サーバーが `cookies()` で読めて転送可。ただし Cookie が全 `*.sz-lab.jp` に拡大(露出リスク)、`SameSite=None`(CSRF 面拡大、Better Auth の origin/CSRF+CORS で緩和)、**localhost dev は別手当が必要**、当該ルートは動的レンダリング固定。 + +決定後、閲覧系(dashboard/stats)の初回データをサーバー取得+`` でストリーミング。検索/フィルタ/ミューテーション/詳細シートはクライアントのままのハイブリッド。`cookies()` 利用で動的化するため PPR の恩恵は限定的(per-user で全動的)。dev/prod の差異とセッションローテーション(Set-Cookie 伝播)に注意。本サブプロジェクトは独立 spec で詳細化する。 + +--- + +## 5. 参照 + +- リサーチ全文(4 スレッド: ppr-caching / loading-suspense / auth-ssr-feasibility / admin-audit): ワークフロー `admin-nextjs-modernization-research` の出力。 +- 本番ドメイン(同一親 `sz-lab.jp`・同サイト別オリジン)はメモリ管理(Public 禁止)。 diff --git a/packages/ui/src/components/data-error.tsx b/packages/ui/src/components/data-error.tsx new file mode 100644 index 0000000..b8af2d1 --- /dev/null +++ b/packages/ui/src/components/data-error.tsx @@ -0,0 +1,17 @@ +import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; + +interface DataErrorProps { + title?: string; + message: string; +} + +// 取得失敗時の共通エラー表示。admin 各画面でインライン重複していた +// destructive Alert を 1 箇所に集約する。 +export function DataError({ title = '読み込めませんでした', message }: DataErrorProps) { + return ( + + {title} + {message} + + ); +} diff --git a/packages/ui/src/components/empty-state.tsx b/packages/ui/src/components/empty-state.tsx new file mode 100644 index 0000000..9e4fa0f --- /dev/null +++ b/packages/ui/src/components/empty-state.tsx @@ -0,0 +1,26 @@ +import { cn } from '@tecnova/ui/lib/utils'; +import type { ComponentType } from 'react'; + +interface EmptyStateProps { + // 任意のアイコン(@tabler/icons-react など className を受け取るコンポーネント)。 + icon?: ComponentType<{ className?: string }>; + message: string; + className?: string; +} + +// 一覧が空のときの共通プレースホルダ。admin 各画面でインライン重複していた +// 「該当なし」ブロックを 1 箇所に集約する。テーブル内の空行には使わない +// (TableCell/colSpan のままにする)。 +export function EmptyState({ icon: Icon, message, className }: EmptyStateProps) { + return ( +
+ {Icon && } + {message} +
+ ); +} diff --git a/packages/ui/src/hooks/use-api-resource.ts b/packages/ui/src/hooks/use-api-resource.ts new file mode 100644 index 0000000..5c7ad0e --- /dev/null +++ b/packages/ui/src/hooks/use-api-resource.ts @@ -0,0 +1,71 @@ +'use client'; + +import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useCallback, useEffect, useState } from 'react'; + +// 取得状態。idle = まだ取得していない(path=null / enabled=false)。 +export type ResourceState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ok'; data: T } + | { kind: 'error'; message: string }; + +export interface UseApiResourceResult { + state: ResourceState; + reload: () => void; +} + +export interface UseApiResourceOptions { + // false のあいだは取得せず idle のままにする(ロール待ち等の条件付き取得用)。 + enabled?: boolean; +} + +// path から JSON を取得し loading|ok|error|idle を返す読み取り専用フック。 +// admin の各画面で重複していた取得+状態機械を 1 箇所に集約する。 +// - path が null か enabled=false のとき idle(取得しない)。 +// - path が変わると自動で再取得する(クエリ文字列を path に含めて +// 検索・フィルタ・ページング・日付変更を表現する)。 +// - reload() で手動再取得(更新ボタン・ミューテーション後の再読込)。 +// アンマウントやパラメータ変更時に古いレスポンスで setState しないよう +// cancelled フラグでガードする(participant-detail-sheet の実装を踏襲)。 +// ミューテーション(POST/PATCH/DELETE)は扱わない。 +export const useApiResource = ( + path: string | null, + options?: UseApiResourceOptions, +): UseApiResourceResult => { + const enabled = options?.enabled ?? true; + // 取得予定なら最初から loading で初期化し、idle の一瞬のちらつきを避ける。 + const [state, setState] = useState>(() => + path && enabled ? { kind: 'loading' } : { kind: 'idle' }, + ); + const [reloadKey, setReloadKey] = useState(0); + + const reload = useCallback(() => { + setReloadKey((k) => k + 1); + }, []); + + // reloadKey は本文では参照しないが、reload() による手動再取得のトリガーとして + // 依存配列に必要(path/enabled が同じでも再フェッチさせる)。 + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey is an intentional refetch trigger + useEffect(() => { + if (!path || !enabled) { + setState({ kind: 'idle' }); + return; + } + let cancelled = false; + setState({ kind: 'loading' }); + void (async () => { + try { + const data = await apiJson(path); + if (!cancelled) setState({ kind: 'ok', data }); + } catch (e) { + if (!cancelled) setState({ kind: 'error', message: apiErrorMessage(e) }); + } + })(); + return () => { + cancelled = true; + }; + }, [path, enabled, reloadKey]); + + return { state, reload }; +};