Skip to content
55 changes: 34 additions & 21 deletions apps/admin/src/app/(authed)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
IconUserCheck,
} from '@tabler/icons-react';
import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas';
import { classifyTerm, TERM_LABELS } 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';
Expand Down Expand Up @@ -160,7 +161,7 @@ function DashboardBody({
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</section>
<TableSkeleton columns={7} rows={6} />
<TableSkeleton columns={8} rows={6} />
</>
);
}
Expand Down Expand Up @@ -196,6 +197,7 @@ function DashboardBody({
<TableHead>氏名</TableHead>
<TableHead>ニックネーム</TableHead>
<TableHead>学年</TableHead>
<TableHead>ターム</TableHead>
<TableHead>チェックイン</TableHead>
<TableHead>チェックアウト</TableHead>
<TableHead>状態</TableHead>
Expand All @@ -204,7 +206,7 @@ function DashboardBody({
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<TableCell colSpan={8}>
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<IconCalendarOff className="size-8" />
<span className="text-sm">
Expand All @@ -216,25 +218,36 @@ function DashboardBody({
</TableCell>
</TableRow>
) : (
rows.map((s) => (
<TableRow
key={s.sessionId}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelectParticipant(s.participantId)}
>
<TableCell className="font-mono">{s.participantId}</TableCell>
<TableCell>{s.fullName}</TableCell>
<TableCell>{s.nickname}</TableCell>
<TableCell>{s.grade}</TableCell>
<TableCell>{fmtTime(s.checkedInAt)}</TableCell>
<TableCell>{s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'}</TableCell>
<TableCell>
<Badge variant={s.isPresent ? 'default' : 'secondary'}>
{s.isPresent ? '来場中' : '退出済'}
</Badge>
</TableCell>
</TableRow>
))
rows.map((s) => {
// セッションは term を持たないので、チェックイン時刻から JST 壁時計で導出する。
const term = classifyTerm(new Date(s.checkedInAt));
return (
<TableRow
key={s.sessionId}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelectParticipant(s.participantId)}
>
<TableCell className="font-mono">{s.participantId}</TableCell>
<TableCell>{s.fullName}</TableCell>
<TableCell>{s.nickname}</TableCell>
<TableCell>{s.grade}</TableCell>
<TableCell>
{term ? (
<Badge variant="secondary">{TERM_LABELS[term]}</Badge>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{fmtTime(s.checkedInAt)}</TableCell>
<TableCell>{s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'}</TableCell>
<TableCell>
<Badge variant={s.isPresent ? 'default' : 'secondary'}>
{s.isPresent ? '来場中' : '退出済'}
</Badge>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
Expand Down
220 changes: 220 additions & 0 deletions apps/admin/src/app/(authed)/stats/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
'use client';

import {
IconCalendarOff,
IconCalendarStats,
IconChartBar,
IconClockHour12,
IconSunHigh,
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 { Input } from '@tecnova/ui/components/input';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@tecnova/ui/components/table';
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
import { formatJstDate } from '@tecnova/ui/lib/format';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/page-header';

type SummaryState =
| { kind: 'loading' }
| { kind: 'ok'; data: ParticipationSummaryResponse }
| { kind: 'error'; message: string };

export default function StatsPage() {
const [summary, setSummary] = useState<SummaryState>({ kind: 'loading' });
// 入力中の値(適用ボタンを押すまで反映しない)。空文字 = フィルタなし。
const [fromInput, setFromInput] = useState('');
const [toInput, setToInput] = useState('');
// 実際に API へ送る確定済みレンジ。
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<ParticipationSummaryResponse>(path);
setSummary({ kind: 'ok', data });
} catch (e) {
setSummary({ kind: 'error', message: apiErrorMessage(e) });
}
}, []);

useEffect(() => {
void loadSummary(appliedFrom, appliedTo);
}, [appliedFrom, appliedTo, loadSummary]);

const applyFilter = () => {
setAppliedFrom(fromInput);
setAppliedTo(toInput);
};

const clearFilter = () => {
setFromInput('');
setToInput('');
setAppliedFrom('');
setAppliedTo('');
};

const hasFilter = appliedFrom !== '' || appliedTo !== '';

return (
<main className="flex flex-1 flex-col gap-6 p-4 md:p-8">
<PageHeader
title="集計"
description="ターム単位の参加回数を期間で集計します"
actions={
<>
<Input
type="date"
aria-label="集計開始日"
value={fromInput}
max={toInput || undefined}
onChange={(e) => setFromInput(e.target.value)}
className="w-40"
/>
<span className="text-sm text-muted-foreground">〜</span>
<Input
type="date"
aria-label="集計終了日"
value={toInput}
min={fromInput || undefined}
onChange={(e) => setToInput(e.target.value)}
className="w-40"
/>
<Button
type="button"
size="sm"
onClick={applyFilter}
disabled={summary.kind === 'loading'}
>
適用
</Button>
{hasFilter && (
<Button type="button" variant="outline" size="sm" onClick={clearFilter}>
全期間
</Button>
)}
</>
}
/>

<StatsBody summary={summary} />
</main>
);
}

function StatsBody({ summary }: { summary: SummaryState }) {
if (summary.kind === 'loading') {
return (
<>
<section className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</section>
<TableSkeleton columns={5} rows={8} />
</>
);
}

if (summary.kind === 'error') {
return (
<Alert variant="destructive">
<AlertTitle>集計を読み込めませんでした</AlertTitle>
<AlertDescription>{summary.message}</AlertDescription>
</Alert>
);
}

const { totals, byDate } = summary.data;

return (
<>
<section className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<SummaryCard label="総参加回数" value={totals.total} Icon={IconChartBar} />
<SummaryCard label="朝" value={totals.morning} Icon={IconSunHigh} />
<SummaryCard label="昼" value={totals.afternoon} Icon={IconClockHour12} />
<SummaryCard label="夕方" value={totals.evening} Icon={IconSunset2} />
<SummaryCard label="開催日数" value={totals.days} Icon={IconCalendarStats} />
</section>

<Card className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>開催日</TableHead>
<TableHead className="text-right">朝</TableHead>
<TableHead className="text-right">昼</TableHead>
<TableHead className="text-right">夕方</TableHead>
<TableHead className="text-right">計</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byDate.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<IconCalendarOff className="size-8" />
<span className="text-sm">この期間の参加実績はありません</span>
</div>
</TableCell>
</TableRow>
) : (
byDate.map((row) => (
<TableRow key={row.date}>
<TableCell>{formatJstDate(row.date)}</TableCell>
<TableCell className="text-right tabular-nums">{row.morning}</TableCell>
<TableCell className="text-right tabular-nums">{row.afternoon}</TableCell>
<TableCell className="text-right tabular-nums">{row.evening}</TableCell>
<TableCell className="text-right font-medium tabular-nums">{row.total}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</>
);
}

function SummaryCard({
label,
value,
Icon,
}: {
label: string;
value: number;
Icon: typeof IconChartBar;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="size-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
</CardContent>
</Card>
);
}
2 changes: 2 additions & 0 deletions apps/admin/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import {
IconChartBar,
IconChevronDown,
IconClipboardList,
IconLayoutDashboard,
Expand Down Expand Up @@ -48,6 +49,7 @@ export function AppShell({ children }: Props) {
const navItems: NavItem[] = [
{ href: '/', label: 'ダッシュボード', Icon: IconLayoutDashboard },
{ href: '/participants', label: '利用者一覧', Icon: IconUsers },
{ href: '/stats', label: '集計', Icon: IconChartBar },
...(me.mentor.role === 'admin'
? [
{
Expand Down
22 changes: 19 additions & 3 deletions apps/admin/src/components/participant-detail-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import type { ParticipantProfileResponse } from '@tecnova/shared/schemas';
import { TERM_LABELS } from '@tecnova/shared/venue-schedule';
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Badge } from '@tecnova/ui/components/badge';
import {
Expand Down Expand Up @@ -143,8 +144,13 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) {
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">ID発行日</dt>
<dd>{formatJstDate(participant.activatedAt)}</dd>
<dt className="text-muted-foreground">累計来場</dt>
<dd>{stats.visitCount} 回</dd>
<dt className="text-muted-foreground">参加回数</dt>
<dd>
{stats.participationCount} 回
<span className="ml-2 text-xs text-muted-foreground">
(累計来場 {stats.visitCount} 回)
</span>
</dd>
<dt className="text-muted-foreground">直近の来場</dt>
<dd>{fmtDateTime(stats.lastVisitedAt)}</dd>
<dt className="text-muted-foreground">累計滞在</dt>
Expand Down Expand Up @@ -178,7 +184,17 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) {
{sessions.map((s) => (
<TableRow key={s.sessionId}>
<TableCell className="px-1.5 py-2 tabular-nums">
{fmtHistoryDateTime(s.checkedInAt)}
<span className="inline-flex flex-wrap items-center gap-1">
{fmtHistoryDateTime(s.checkedInAt)}
{s.term && (
<Badge
variant={s.counted ? 'secondary' : 'outline'}
className="px-1 py-0 text-[10px] leading-tight"
>
{TERM_LABELS[s.term]}
</Badge>
)}
</span>
</TableCell>
<TableCell className="px-1.5 py-2 tabular-nums">
{fmtHistoryDateTime(s.checkedOutAt)}
Expand Down
Loading
Loading