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
29 changes: 29 additions & 0 deletions apps/admin/src/app/(authed)/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { Button } from '@tecnova/ui/components/button';
import { DataError } from '@tecnova/ui/components/data-error';

// 認証必須セクションの描画時クラッシュを拾う Error Boundary。
// 各ページの try/catch では拾えない描画中の throw をここで受ける。
// error.tsx は Client Component 必須。
export default function AuthedError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<div className="w-full max-w-md">
<DataError
title="エラーが発生しました"
message={error.message || '予期しないエラーが発生しました'}
/>
</div>
<Button type="button" variant="outline" onClick={reset}>
再試行
</Button>
</main>
);
}
10 changes: 6 additions & 4 deletions apps/admin/src/app/(authed)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { MeProvider } from '@tecnova/ui/components/me-provider';
import { MeGate, 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 は別ルートグループなので
// このレイアウトは適用されない
// AppShell(サイドバー等のクローム)は認証解決を待たずに即描画する。
// ページ本文だけ MeGate でゲートする(即時シェル)。/login は別ルートグループ
// CRUD のフィードバックはここに置いた Toaster でまとめて受ける。
export default function AuthedLayout({ children }: { children: React.ReactNode }) {
return (
<MeProvider>
<AppShell>{children}</AppShell>
<AppShell>
<MeGate>{children}</MeGate>
</AppShell>
<Toaster richColors position="top-right" />
</MeProvider>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/admin/src/app/(authed)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Skeleton } from '@tecnova/ui/components/skeleton';

// ソフトナビ時のコンテンツスケルトン。即時シェル化により AppShell(サイドバー等)は
// 保たれるため、本文スロットだけがこのフォールバックに置き換わる。
export default function AuthedLoading() {
return (
<div className="flex flex-1 flex-col gap-6 p-4 md:p-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-72" />
<Skeleton className="h-64 w-full" />
</div>
);
}
64 changes: 20 additions & 44 deletions apps/admin/src/app/(authed)/mentors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<State>({ kind: 'loading' });

const load = useCallback(async () => {
setState({ kind: 'loading' });
try {
const data = await apiJson<MentorsListResponse>('/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<MentorsListResponse>('/api/mentors', {
enabled: me.mentor.role === 'admin',
});

// ガード: ナビには非表示だが、URL 直叩き対策。/api/mentors も 403 で弾かれる。
if (me.mentor.role !== 'admin') {
Expand All @@ -95,7 +78,7 @@ export default function MentorsPage() {
</Reveal>

<Reveal index={1}>
<CreateMentorForm onCreated={load} />
<CreateMentorForm onCreated={reload} />
</Reveal>

{/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。
Expand All @@ -113,18 +96,11 @@ export default function MentorsPage() {
</div>
</>
)}
{state.kind === 'error' && (
<Alert variant="destructive">
<AlertTitle>読み込めませんでした</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}
{state.kind === 'error' && <DataError message={state.message} />}

{state.kind === 'ok' &&
(state.mentors.length === 0 ? (
<div className="rounded-2xl border bg-card px-4 py-10 text-center text-sm text-muted-foreground">
まだ管理者が登録されていません
</div>
(state.data.mentors.length === 0 ? (
<EmptyState message="まだ管理者が登録されていません" />
) : (
<>
{/* モバイル: カードリスト。
Expand All @@ -133,8 +109,8 @@ export default function MentorsPage() {
未保存の編集が見かけ上消える。admin の利用端末(PC / タブレット)では稀で、
保存すれば再取得で両者が同期するため許容するトレードオフ。 */}
<div className="flex flex-col gap-3 md:hidden">
{state.mentors.map((m) => (
<MentorRow key={m.id} mentor={m} onUpdated={load} variant="card" />
{state.data.mentors.map((m) => (
<MentorRow key={m.id} mentor={m} onUpdated={reload} variant="card" />
))}
</div>

Expand All @@ -153,8 +129,8 @@ export default function MentorsPage() {
</TableRow>
</TableHeader>
<TableBody>
{state.mentors.map((m) => (
<MentorRow key={m.id} mentor={m} onUpdated={load} variant="row" />
{state.data.mentors.map((m) => (
<MentorRow key={m.id} mentor={m} onUpdated={reload} variant="row" />
))}
</TableBody>
</Table>
Expand All @@ -167,7 +143,7 @@ export default function MentorsPage() {
);
}

function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
function CreateMentorForm({ onCreated }: { onCreated: () => void }) {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<'admin' | 'mentor'>('mentor');
Expand All @@ -184,7 +160,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise<void> }) {
setEmail('');
setName('');
setRole('mentor');
await onCreated();
onCreated();
} catch (e) {
toastError(e, '管理者を追加できませんでした');
} finally {
Expand Down Expand Up @@ -248,7 +224,7 @@ function MentorRow({
variant,
}: {
mentor: MentorItem;
onUpdated: () => Promise<void>;
onUpdated: () => void;
// 'row' = デスクトップのテーブル行 / 'card' = モバイルのカード
variant: 'row' | 'card';
}) {
Expand All @@ -271,7 +247,7 @@ function MentorRow({
if (active !== mentor.active) body.active = active;
await apiJson<MentorItem>(`/api/mentors/${mentor.id}`, { method: 'PATCH', body });
toastSuccess(`${mentor.name} を保存しました`);
await onUpdated();
onUpdated();
} catch (e) {
toastError(e, '保存できませんでした');
} finally {
Expand Down
67 changes: 18 additions & 49 deletions apps/admin/src/app/(authed)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__';
Expand All @@ -57,40 +52,19 @@ const fmtTime = (iso: string): string =>
}).format(new Date(iso));

export default function DashboardPage() {
const [sessions, setSessions] = useState<SessionsState>({ kind: 'loading' });
const [events, setEvents] = useState<EventsListResponse['events']>([]);
const [selectedDate, setSelectedDate] = useState<string>(TODAY_VALUE);
const [selectedParticipantId, setSelectedParticipantId] = useState<string | null>(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<TodaySessionsResponse>(path);
setSessions({ kind: 'ok', data });
} catch (e) {
setSessions({ kind: 'error', message: apiErrorMessage(e) });
}
}, []);

useEffect(() => {
// イベント一覧は失敗しても致命ではないので、エラーは表示せず空で続行する。
void (async () => {
try {
const r = await apiJson<EventsListResponse>('/api/events');
setEvents(r.events);
} catch {
setEvents([]);
}
})();
}, []);
// 日付を path に含めることで、選択変更で自動再取得される。更新ボタンは reload()。
const sessionsPath =
selectedDate === TODAY_VALUE
? '/api/sessions'
: `/api/sessions?date=${encodeURIComponent(selectedDate)}`;
const sessions = useApiResource<TodaySessionsResponse>(sessionsPath);

useEffect(() => {
void loadSessions(selectedDate);
}, [selectedDate, loadSessions]);
// イベント一覧は失敗しても致命ではないので、取得できたときだけ使う(エラーは無視)。
const eventsResource = useApiResource<EventsListResponse>('/api/events');
const events = eventsResource.state.kind === 'ok' ? eventsResource.state.data.events : [];

const today = toJstDateString(new Date());
// 「本日」ラベル + イベントとして登録済みの過去日を結合する。
Expand Down Expand Up @@ -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'}
>
<IconRefresh data-icon="inline-start" />
更新
Expand All @@ -137,7 +111,7 @@ export default function DashboardPage() {
常時マウントなので入場は一度だけ(再フェッチで再生されない)。 */}
<Reveal index={1} className="flex flex-col gap-6">
<DashboardBody
sessions={sessions}
sessions={sessions.state}
onSelectParticipant={(id) => setSelectedParticipantId(id)}
/>
</Reveal>
Expand All @@ -156,10 +130,10 @@ function DashboardBody({
sessions,
onSelectParticipant,
}: {
sessions: SessionsState;
sessions: ResourceState<TodaySessionsResponse>;
onSelectParticipant: (id: string) => void;
}) {
if (sessions.kind === 'loading') {
if (sessions.kind === 'loading' || sessions.kind === 'idle') {
return (
<>
<section className="grid grid-cols-3 gap-3 md:gap-4">
Expand All @@ -180,12 +154,7 @@ function DashboardBody({
}

if (sessions.kind === 'error') {
return (
<Alert variant="destructive">
<AlertTitle>セッションを読み込めませんでした</AlertTitle>
<AlertDescription>{sessions.message}</AlertDescription>
</Alert>
);
return <DataError title="セッションを読み込めませんでした" message={sessions.message} />;
}

const { event, sessions: rows, summary } = sessions.data;
Expand Down
Loading
Loading