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
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
51 changes: 14 additions & 37 deletions apps/admin/src/app/(authed)/participants/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,27 +25,21 @@ 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';
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 を受け付けない。
const ANY_GRADE = '__any_grade__';
const ANY_ACTIVE = '__any_active__';

export default function ParticipantsPage() {
const [state, setState] = useState<State>({ kind: 'loading' });
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [grade, setGrade] = useState<string>(ANY_GRADE);
Expand Down Expand Up @@ -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<ParticipantsListResponse>(
`/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<ParticipantsListResponse>(
`/api/participants?${params.toString()}`,
);

const totalPages =
state.kind === 'ok' ? Math.max(1, Math.ceil(state.data.pagination.total / PAGE_SIZE)) : 1;
Expand Down Expand Up @@ -172,21 +156,14 @@ export default function ParticipantsPage() {
</>
)}

{state.kind === 'error' && (
<Alert variant="destructive">
<AlertTitle>読み込めませんでした</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}
{state.kind === 'error' && <DataError message={state.message} />}

{state.kind === 'ok' && (
<>
{/* モバイル: カードリスト */}
<div className="flex flex-col gap-3 md:hidden">
{state.data.participants.length === 0 ? (
<div className="rounded-2xl border bg-card px-4 py-10 text-center text-sm text-muted-foreground">
該当する利用者が見つかりません
</div>
<EmptyState message="該当する利用者が見つかりません" />
) : (
state.data.participants.map((p) => (
<RecordCard
Expand Down
Loading
Loading