Skip to content

Commit 4861633

Browse files
authored
Merge pull request #36 from ut42tech/develop
Release to production: term participation tracking + checkin motion redesign
2 parents 9f39762 + 444c0d8 commit 4861633

47 files changed

Lines changed: 5712 additions & 1004 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,5 @@ temp/
227227
# =============================================================================
228228
drizzle/.snapshot.*
229229

230-
.vercel
230+
.vercel
231+
.superpowers

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"js/ts.preferences.importModuleSpecifier": "non-relative",
33
"files.associations": {
44
"*.css": "tailwindcss"
5-
}
5+
},
6+
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
67
}

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
## プロジェクト概要
99

10-
**tecnova-platform** は長崎大学NUTICで開催される子ども向けファブリケーション活動「テクノバながさきの運営基盤プラットフォームです。
10+
**tecnova-platform** は、長崎市と長崎大学による共同事業として開催される子ども向けファブリケーション活動 **tec-nova Nagasaki(テクノバながさき** の運営基盤プラットフォームです。
1111
モノレポ構成で、APIサーバ(Hono on Cloudflare Workers)と複数のフロントエンド(Next.js)を含みます。
1212

1313
詳細な要件・設計は以下を参照してください。**実装前に必ず読むこと**

apps/admin/src/app/(authed)/page.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
IconUserCheck,
99
} from '@tabler/icons-react';
1010
import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas';
11+
import { toJstDateString } from '@tecnova/shared/venue-schedule';
1112
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
1213
import { Badge } from '@tecnova/ui/components/badge';
1314
import { Button } from '@tecnova/ui/components/button';
@@ -29,6 +30,7 @@ import {
2930
TableRow,
3031
} from '@tecnova/ui/components/table';
3132
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
33+
import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge';
3234
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
3335
import { useCallback, useEffect, useState } from 'react';
3436
import { PageHeader } from '@/components/page-header';
@@ -51,10 +53,6 @@ const fmtTime = (iso: string): string =>
5153
minute: '2-digit',
5254
}).format(new Date(iso));
5355

54-
// JST の YYYY-MM-DD を返す(events.date と同形)。
55-
const todayInJst = (): string =>
56-
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(new Date());
57-
5856
export default function DashboardPage() {
5957
const [sessions, setSessions] = useState<SessionsState>({ kind: 'loading' });
6058
const [events, setEvents] = useState<EventsListResponse['events']>([]);
@@ -91,7 +89,7 @@ export default function DashboardPage() {
9189
void loadSessions(selectedDate);
9290
}, [selectedDate, loadSessions]);
9391

94-
const today = todayInJst();
92+
const today = toJstDateString(new Date());
9593
// 「本日」ラベル + イベントとして登録済みの過去日を結合する。
9694
// 今日の event が events に含まれていてもメニューの重複は避ける。
9795
const pastEvents = events.filter((e) => e.date !== today);
@@ -160,7 +158,7 @@ function DashboardBody({
160158
<Skeleton className="h-24 w-full" />
161159
<Skeleton className="h-24 w-full" />
162160
</section>
163-
<TableSkeleton columns={7} rows={6} />
161+
<TableSkeleton columns={8} rows={6} />
164162
</>
165163
);
166164
}
@@ -196,6 +194,7 @@ function DashboardBody({
196194
<TableHead>氏名</TableHead>
197195
<TableHead>ニックネーム</TableHead>
198196
<TableHead>学年</TableHead>
197+
<TableHead>ターム</TableHead>
199198
<TableHead>チェックイン</TableHead>
200199
<TableHead>チェックアウト</TableHead>
201200
<TableHead>状態</TableHead>
@@ -204,7 +203,7 @@ function DashboardBody({
204203
<TableBody>
205204
{rows.length === 0 ? (
206205
<TableRow>
207-
<TableCell colSpan={7}>
206+
<TableCell colSpan={8}>
208207
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
209208
<IconCalendarOff className="size-8" />
210209
<span className="text-sm">
@@ -226,6 +225,16 @@ function DashboardBody({
226225
<TableCell>{s.fullName}</TableCell>
227226
<TableCell>{s.nickname}</TableCell>
228227
<TableCell>{s.grade}</TableCell>
228+
<TableCell>
229+
{s.term ? (
230+
<div className="flex flex-wrap items-center gap-1">
231+
<TermBadge term={s.term} counted={s.counted} />
232+
{!s.counted && <UncountedBadge />}
233+
</div>
234+
) : (
235+
<span className="text-muted-foreground"></span>
236+
)}
237+
</TableCell>
229238
<TableCell>{fmtTime(s.checkedInAt)}</TableCell>
230239
<TableCell>{s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'}</TableCell>
231240
<TableCell>
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
'use client';
2+
3+
import {
4+
IconCalendarOff,
5+
IconCalendarStats,
6+
IconChartBar,
7+
IconClockHour12,
8+
IconSunHigh,
9+
IconSunset2,
10+
} from '@tabler/icons-react';
11+
import type { ParticipationSummaryResponse } from '@tecnova/shared/schemas';
12+
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
13+
import { Button } from '@tecnova/ui/components/button';
14+
import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card';
15+
import { Input } from '@tecnova/ui/components/input';
16+
import { Skeleton } from '@tecnova/ui/components/skeleton';
17+
import {
18+
Table,
19+
TableBody,
20+
TableCell,
21+
TableHead,
22+
TableHeader,
23+
TableRow,
24+
} from '@tecnova/ui/components/table';
25+
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
26+
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
27+
import { formatJstDate } from '@tecnova/ui/lib/format';
28+
import { cn } from '@tecnova/ui/lib/utils';
29+
import { useCallback, useEffect, useState } from 'react';
30+
import { PageHeader } from '@/components/page-header';
31+
32+
type SummaryState =
33+
| { kind: 'loading' }
34+
| { kind: 'ok'; data: ParticipationSummaryResponse }
35+
| { kind: 'error'; message: string };
36+
37+
export default function StatsPage() {
38+
const [summary, setSummary] = useState<SummaryState>({ kind: 'loading' });
39+
// 入力中の値(適用ボタンを押すまで反映しない)。空文字 = フィルタなし。
40+
const [fromInput, setFromInput] = useState('');
41+
const [toInput, setToInput] = useState('');
42+
// 実際に API へ送る確定済みレンジ。
43+
const [appliedFrom, setAppliedFrom] = useState('');
44+
const [appliedTo, setAppliedTo] = useState('');
45+
46+
const loadSummary = useCallback(async (from: string, to: string) => {
47+
setSummary({ kind: 'loading' });
48+
try {
49+
const params = new URLSearchParams();
50+
if (from) params.set('from', from);
51+
if (to) params.set('to', to);
52+
const query = params.toString();
53+
const path = query ? `/api/stats/participation?${query}` : '/api/stats/participation';
54+
const data = await apiJson<ParticipationSummaryResponse>(path);
55+
setSummary({ kind: 'ok', data });
56+
} catch (e) {
57+
setSummary({ kind: 'error', message: apiErrorMessage(e) });
58+
}
59+
}, []);
60+
61+
useEffect(() => {
62+
void loadSummary(appliedFrom, appliedTo);
63+
}, [appliedFrom, appliedTo, loadSummary]);
64+
65+
const applyFilter = () => {
66+
setAppliedFrom(fromInput);
67+
setAppliedTo(toInput);
68+
};
69+
70+
const clearFilter = () => {
71+
setFromInput('');
72+
setToInput('');
73+
setAppliedFrom('');
74+
setAppliedTo('');
75+
};
76+
77+
const hasFilter = appliedFrom !== '' || appliedTo !== '';
78+
79+
return (
80+
<main className="flex flex-1 flex-col gap-6 p-4 md:p-8">
81+
<PageHeader
82+
title="集計"
83+
description="ターム単位の参加回数を期間で集計します"
84+
actions={
85+
<>
86+
<Input
87+
type="date"
88+
aria-label="集計開始日"
89+
value={fromInput}
90+
max={toInput || undefined}
91+
onChange={(e) => setFromInput(e.target.value)}
92+
className="w-40"
93+
/>
94+
<span className="text-sm text-muted-foreground"></span>
95+
<Input
96+
type="date"
97+
aria-label="集計終了日"
98+
value={toInput}
99+
min={fromInput || undefined}
100+
onChange={(e) => setToInput(e.target.value)}
101+
className="w-40"
102+
/>
103+
<Button
104+
type="button"
105+
size="sm"
106+
onClick={applyFilter}
107+
disabled={summary.kind === 'loading'}
108+
>
109+
適用
110+
</Button>
111+
{hasFilter && (
112+
<Button type="button" variant="outline" size="sm" onClick={clearFilter}>
113+
全期間
114+
</Button>
115+
)}
116+
</>
117+
}
118+
/>
119+
120+
<StatsBody summary={summary} />
121+
</main>
122+
);
123+
}
124+
125+
function StatsBody({ summary }: { summary: SummaryState }) {
126+
if (summary.kind === 'loading') {
127+
return (
128+
<>
129+
<section className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
130+
<Skeleton className="h-24 w-full" />
131+
<Skeleton className="h-24 w-full" />
132+
<Skeleton className="h-24 w-full" />
133+
<Skeleton className="h-24 w-full" />
134+
<Skeleton className="h-24 w-full" />
135+
</section>
136+
<TableSkeleton columns={5} rows={8} />
137+
</>
138+
);
139+
}
140+
141+
if (summary.kind === 'error') {
142+
return (
143+
<Alert variant="destructive">
144+
<AlertTitle>集計を読み込めませんでした</AlertTitle>
145+
<AlertDescription>{summary.message}</AlertDescription>
146+
</Alert>
147+
);
148+
}
149+
150+
const { totals, byDate } = summary.data;
151+
152+
return (
153+
<>
154+
<section className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
155+
<SummaryCard label="総参加回数" value={totals.total} Icon={IconChartBar} />
156+
<SummaryCard
157+
label="朝"
158+
value={totals.morning}
159+
Icon={IconSunHigh}
160+
iconClassName="text-sky-600"
161+
/>
162+
<SummaryCard
163+
label="昼"
164+
value={totals.afternoon}
165+
Icon={IconClockHour12}
166+
iconClassName="text-amber-600"
167+
/>
168+
<SummaryCard
169+
label="夕方"
170+
value={totals.evening}
171+
Icon={IconSunset2}
172+
iconClassName="text-violet-600"
173+
/>
174+
<SummaryCard label="開催日数" value={totals.days} Icon={IconCalendarStats} />
175+
</section>
176+
177+
<Card className="p-0">
178+
<Table>
179+
<TableHeader>
180+
<TableRow>
181+
<TableHead>開催日</TableHead>
182+
<TableHead className="text-right"></TableHead>
183+
<TableHead className="text-right"></TableHead>
184+
<TableHead className="text-right">夕方</TableHead>
185+
<TableHead className="text-right"></TableHead>
186+
</TableRow>
187+
</TableHeader>
188+
<TableBody>
189+
{byDate.length === 0 ? (
190+
<TableRow>
191+
<TableCell colSpan={5}>
192+
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
193+
<IconCalendarOff className="size-8" />
194+
<span className="text-sm">この期間の参加実績はありません</span>
195+
</div>
196+
</TableCell>
197+
</TableRow>
198+
) : (
199+
byDate.map((row) => (
200+
<TableRow key={row.date}>
201+
<TableCell>{formatJstDate(row.date)}</TableCell>
202+
<TableCell className="text-right tabular-nums">{row.morning}</TableCell>
203+
<TableCell className="text-right tabular-nums">{row.afternoon}</TableCell>
204+
<TableCell className="text-right tabular-nums">{row.evening}</TableCell>
205+
<TableCell className="text-right font-medium tabular-nums">{row.total}</TableCell>
206+
</TableRow>
207+
))
208+
)}
209+
</TableBody>
210+
</Table>
211+
</Card>
212+
</>
213+
);
214+
}
215+
216+
function SummaryCard({
217+
label,
218+
value,
219+
Icon,
220+
iconClassName,
221+
}: {
222+
label: string;
223+
value: number;
224+
Icon: typeof IconChartBar;
225+
iconClassName?: string;
226+
}) {
227+
return (
228+
<Card>
229+
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
230+
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
231+
<Icon className={cn('size-5 text-muted-foreground', iconClassName)} />
232+
</CardHeader>
233+
<CardContent>
234+
<div className="text-3xl font-bold">{value}</div>
235+
</CardContent>
236+
</Card>
237+
);
238+
}

apps/admin/src/components/app-shell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import {
4+
IconChartBar,
45
IconChevronDown,
56
IconClipboardList,
67
IconLayoutDashboard,
@@ -48,6 +49,7 @@ export function AppShell({ children }: Props) {
4849
const navItems: NavItem[] = [
4950
{ href: '/', label: 'ダッシュボード', Icon: IconLayoutDashboard },
5051
{ href: '/participants', label: '利用者一覧', Icon: IconUsers },
52+
{ href: '/stats', label: '集計', Icon: IconChartBar },
5153
...(me.mentor.role === 'admin'
5254
? [
5355
{

0 commit comments

Comments
 (0)