From 90691b25f4997ea8e4da8a5912ec96749c3ebcbd Mon Sep 17 00:00:00 2001 From: tmba Date: Wed, 13 May 2026 22:06:59 +0900 Subject: [PATCH 1/2] feat: add PageHeader and ParticipantDetailSheet components - Introduced PageHeader component for consistent page headers with title, description, and actions. - Added ParticipantDetailSheet component to display participant details, including visit history and current status. - Implemented date formatting functions for displaying timestamps in JST. - Enhanced API with fetchSessionsForEvent and fetchEventsList functions for improved session management. - Updated participants list query to support filtering by grade and active status. - Integrated Sonner for toast notifications and added utility functions for success and error toasts. - Created reusable UI components including DropdownMenu, Sheet, and Tooltip. - Added TableSkeleton for loading states in tables. --- apps/admin/src/app/(authed)/layout.tsx | 3 + apps/admin/src/app/(authed)/mentors/page.tsx | 192 +++++++------- apps/admin/src/app/(authed)/page.tsx | 213 ++++++++++++--- .../src/app/(authed)/participants/page.tsx | 151 +++++++++-- .../app/(authed)/pre-registrations/page.tsx | 98 ++++--- apps/admin/src/app/login/page.tsx | 15 +- apps/admin/src/components/app-shell.tsx | 84 ++++-- apps/admin/src/components/page-header.tsx | 24 ++ .../components/participant-detail-sheet.tsx | 198 ++++++++++++++ apps/api/src/lib/admin.ts | 56 +++- apps/api/src/routes/admin.ts | 21 +- packages/shared/src/schemas/admin.ts | 36 +++ packages/ui/package.json | 1 + packages/ui/src/components/dropdown-menu.tsx | 246 ++++++++++++++++++ packages/ui/src/components/sheet.tsx | 130 +++++++++ packages/ui/src/components/sonner.tsx | 43 +++ packages/ui/src/components/table-skeleton.tsx | 50 ++++ packages/ui/src/components/tooltip.tsx | 52 ++++ packages/ui/src/lib/toast.ts | 14 + pnpm-lock.yaml | 14 + 20 files changed, 1399 insertions(+), 242 deletions(-) create mode 100644 apps/admin/src/components/page-header.tsx create mode 100644 apps/admin/src/components/participant-detail-sheet.tsx create mode 100644 packages/ui/src/components/dropdown-menu.tsx create mode 100644 packages/ui/src/components/sheet.tsx create mode 100644 packages/ui/src/components/sonner.tsx create mode 100644 packages/ui/src/components/table-skeleton.tsx create mode 100644 packages/ui/src/components/tooltip.tsx create mode 100644 packages/ui/src/lib/toast.ts diff --git a/apps/admin/src/app/(authed)/layout.tsx b/apps/admin/src/app/(authed)/layout.tsx index 5691c53..522f955 100644 --- a/apps/admin/src/app/(authed)/layout.tsx +++ b/apps/admin/src/app/(authed)/layout.tsx @@ -1,13 +1,16 @@ import { 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 は別ルートグループなので // このレイアウトは適用されない。 +// CRUD のフィードバックはここに置いた Toaster でまとめて受ける。 export default function AuthedLayout({ children }: { children: React.ReactNode }) { return ( {children} + ); } diff --git a/apps/admin/src/app/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx index 124e715..44a5007 100644 --- a/apps/admin/src/app/(authed)/mentors/page.tsx +++ b/apps/admin/src/app/(authed)/mentors/page.tsx @@ -20,7 +20,6 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; -import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -29,9 +28,19 @@ import { TableHeader, TableRow, } from '@tecnova/ui/components/table'; -import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@tecnova/ui/components/tooltip'; +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 { PageHeader } from '@/components/page-header'; type State = | { kind: 'loading' } @@ -48,7 +57,10 @@ export default function MentorsPage() { const data = await apiJson('/api/mentors'); setState({ kind: 'ok', mentors: data.mentors }); } catch (e) { - setState({ kind: 'error', message: apiErrorMessage(e) }); + setState({ + kind: 'error', + message: e instanceof Error ? e.message : String(e), + }); } }, []); @@ -70,50 +82,53 @@ export default function MentorsPage() { } return ( -
-
-

メンター管理

-
+ +
+ - + - {state.kind === 'loading' && } - {state.kind === 'error' && ( - - エラー - {state.message} - - )} + {state.kind === 'loading' && } + {state.kind === 'error' && ( + + 読み込めませんでした + {state.message} + + )} - {state.kind === 'ok' && ( - - - - - メールアドレス - 名前 - ロール - 状態 - 登録日 - 最終ログイン - 操作 - - - - {state.mentors.length === 0 ? ( + {state.kind === 'ok' && ( + +
+ - - 該当データがありません - + メールアドレス + 名前 + ロール + 状態 + 登録日 + 最終ログイン + 操作 - ) : ( - state.mentors.map((m) => ) - )} - -
-
- )} -
+ + + {state.mentors.length === 0 ? ( + + + まだ管理者が登録されていません + + + ) : ( + state.mentors.map((m) => ) + )} + + + + )} +
+ ); } @@ -122,22 +137,21 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { const [name, setName] = useState(''); const [role, setRole] = useState<'admin' | 'mentor'>('mentor'); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); const submit = async (e: FormEvent) => { e.preventDefault(); if (busy) return; setBusy(true); - setError(null); try { const body: CreateMentorRequest = { email, name, role }; await apiJson('/api/mentors', { method: 'POST', body }); + toastSuccess(`${name} を追加しました`); setEmail(''); setName(''); setRole('mentor'); await onCreated(); } catch (e) { - setError(apiErrorMessage(e)); + toastError(e, '管理者を追加できませんでした'); } finally { setBusy(false); } @@ -147,7 +161,7 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) {
- メンター追加 + 管理者追加
@@ -187,12 +201,6 @@ function CreateMentorForm({ onCreated }: { onCreated: () => Promise }) { {busy ? '送信中...' : '追加'}
- {error && ( - - 追加できませんでした - {error} - - )}
@@ -204,7 +212,6 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => const [role, setRole] = useState(mentor.role); const [active, setActive] = useState(mentor.active); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); const dirty = role !== mentor.role || active !== mentor.active; // 自分自身のロール降格 / 無効化は禁止(最後の admin が自分を外して詰むのを避ける) @@ -214,64 +221,75 @@ function MentorRow({ mentor, onUpdated }: { mentor: MentorItem; onUpdated: () => const save = async () => { if (!dirty || busy) return; setBusy(true); - setError(null); try { const body: UpdateMentorRequest = {}; if (role !== mentor.role) body.role = role; if (active !== mentor.active) body.active = active; await apiJson(`/api/mentors/${mentor.id}`, { method: 'PATCH', body }); + toastSuccess(`${mentor.name} を保存しました`); await onUpdated(); } catch (e) { - setError(apiErrorMessage(e)); + toastError(e, '保存できませんでした'); } finally { setBusy(false); } }; + // 自分自身の行の操作 UI は、Tooltip で理由を添えてグレーアウトする。 + const wrapSelfReadonly = (node: React.ReactNode) => + isSelf ? ( + + + {node} + + 自分自身は変更できません + + ) : ( + node + ); + return ( - + {mentor.email} {mentor.name} - + {wrapSelfReadonly( + , + )} - + {wrapSelfReadonly( + , + )} {formatJstDate(mentor.createdAt)} {formatJstDate(mentor.lastLoginAt)} -
+ {wrapSelfReadonly( - {error && ( - - {error} - - )} - {isSelf &&

自分自身は変更不可

} -
+ , + )}
); diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index 9cbffbe..f770906 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -1,9 +1,24 @@ 'use client'; -import type { TodaySessionsResponse } from '@tecnova/shared/schemas'; +import { + IconCalendarOff, + IconLogin2, + IconLogout2, + IconRefresh, + IconUserCheck, +} from '@tabler/icons-react'; +import type { EventsListResponse, TodaySessionsResponse } 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, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tecnova/ui/components/select'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, @@ -13,14 +28,21 @@ import { TableHeader, TableRow, } from '@tecnova/ui/components/table'; -import { ApiError, apiJson } from '@tecnova/ui/lib/api-client'; -import { useEffect, useState } from 'react'; +import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; +import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; +import { useCallback, useEffect, useState } from 'react'; +import { PageHeader } from '@/components/page-header'; +import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; -type State = +type SessionsState = | { kind: 'loading' } | { kind: 'ok'; data: TodaySessionsResponse } | { kind: 'error'; message: string }; +// セレクタで「今日」を選んでいる状態のセンチネル値。 +// 空文字や undefined を使うと Select の制御値として扱いにくいのでこの形に。 +const TODAY_VALUE = '__today__'; + // UTC ISO 文字列を JST の HH:mm 表記に整形する。 const fmtTime = (iso: string): string => new Intl.DateTimeFormat('ja-JP', { @@ -29,56 +51,141 @@ const fmtTime = (iso: string): string => minute: '2-digit', }).format(new Date(iso)); +// JST の YYYY-MM-DD を返す(events.date と同形)。 +const todayInJst = (): string => + new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(new Date()); + export default function DashboardPage() { - const [state, setState] = useState({ kind: 'loading' }); + 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 data = await apiJson('/api/sessions/today'); - setState({ kind: 'ok', data }); - } catch (e) { - const message = - e instanceof ApiError ? `HTTP ${e.status}` : e instanceof Error ? e.message : String(e); - setState({ kind: 'error', message }); + const r = await apiJson('/api/events'); + setEvents(r.events); + } catch { + setEvents([]); } })(); }, []); - if (state.kind === 'loading') { + useEffect(() => { + void loadSessions(selectedDate); + }, [selectedDate, loadSessions]); + + const today = todayInJst(); + // 「本日」ラベル + イベントとして登録済みの過去日を結合する。 + // 今日の event が events に含まれていてもメニューの重複は避ける。 + const pastEvents = events.filter((e) => e.date !== today); + + return ( +
+ + + + + } + /> + + setSelectedParticipantId(id)} + /> + + { + if (!open) setSelectedParticipantId(null); + }} + /> +
+ ); +} + +function DashboardBody({ + sessions, + onSelectParticipant, +}: { + sessions: SessionsState; + onSelectParticipant: (id: string) => void; +}) { + if (sessions.kind === 'loading') { return ( -
- -
+ <> +
+ + + +
+ + ); } - if (state.kind === 'error') { + if (sessions.kind === 'error') { return ( -
- - エラー - {state.message} - -
+ + セッションを読み込めませんでした + {sessions.message} + ); } - const { event, sessions, summary } = state.data; + const { event, sessions: rows, summary } = sessions.data; return ( -
-
-

本日のセッション

- - {event ? `イベント日付: ${event.date}` : '本日はまだチェックインがありません'} - -
- + <>
- - - + + +
@@ -95,15 +202,26 @@ export default function DashboardPage() { - {sessions.length === 0 ? ( + {rows.length === 0 ? ( - - 該当データがありません + +
+ + + {event + ? 'このイベントのセッションはまだありません' + : 'この日のイベントはまだ作成されていません'} + +
) : ( - sessions.map((s) => ( - + rows.map((s) => ( + onSelectParticipant(s.participantId)} + > {s.participantId} {s.fullName} {s.nickname} @@ -121,15 +239,24 @@ export default function DashboardPage() {
-
+ ); } -function SummaryCard({ label, value }: { label: string; value: number }) { +function SummaryCard({ + label, + value, + Icon, +}: { + label: string; + value: number; + Icon: typeof IconUserCheck; +}) { return ( - - {label} + + {label} +
{value}
diff --git a/apps/admin/src/app/(authed)/participants/page.tsx b/apps/admin/src/app/(authed)/participants/page.tsx index dd12089..5e873e5 100644 --- a/apps/admin/src/app/(authed)/participants/page.tsx +++ b/apps/admin/src/app/(authed)/participants/page.tsx @@ -1,12 +1,19 @@ 'use client'; -import type { ParticipantsListResponse } from '@tecnova/shared/schemas'; +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 { Input } from '@tecnova/ui/components/input'; -import { Skeleton } from '@tecnova/ui/components/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tecnova/ui/components/select'; import { Table, TableBody, @@ -15,9 +22,12 @@ import { 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 { useEffect, useState } from 'react'; +import { PageHeader } from '@/components/page-header'; +import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; type State = | { kind: 'loading' } @@ -26,11 +36,18 @@ type State = const PAGE_SIZE = 50; +// 「すべて」を表すセンチネル値。SelectItem は空文字 value を受け付けない。 +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); + const [activeFilter, setActiveFilter] = useState(ANY_ACTIVE); const [page, setPage] = useState(1); + const [selectedParticipantId, setSelectedParticipantId] = useState(null); // 入力のたびに API を叩かないよう 300ms デバウンス。 useEffect(() => { @@ -41,6 +58,18 @@ export default function ParticipantsPage() { return () => clearTimeout(id); }, [search]); + // フィルタ用 setter。Select の onValueChange に直接渡す。 + // 切替時にページを 1 に戻したいので、setState を併せて呼ぶラッパに分けている + // (useEffect で副作用にすると、setPage が空依存と判定されて lint が誤検知する)。 + const updateGrade = (v: string) => { + setGrade(v); + setPage(1); + }; + const updateActiveFilter = (v: string) => { + setActiveFilter(v); + setPage(1); + }; + useEffect(() => { void (async () => { setState({ kind: 'loading' }); @@ -50,6 +79,8 @@ export default function ParticipantsPage() { 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()}`, ); @@ -58,29 +89,73 @@ export default function ParticipantsPage() { setState({ kind: 'error', message: apiErrorMessage(e) }); } })(); - }, [debouncedSearch, page]); + }, [debouncedSearch, page, grade, activeFilter]); const totalPages = state.kind === 'ok' ? Math.max(1, Math.ceil(state.data.pagination.total / PAGE_SIZE)) : 1; return ( -
-
-

参加者一覧

- setSearch(e.target.value)} - className="max-w-xs" - /> +
+ + +
+
+ + setSearch(e.target.value)} + className="pr-9 pl-9" + /> + {search && ( + + )} +
+ + + +
- {state.kind === 'loading' && } + {state.kind === 'loading' && } {state.kind === 'error' && ( - エラー + 読み込めませんでした {state.message} )} @@ -95,20 +170,24 @@ export default function ParticipantsPage() { 氏名 ニックネーム 学年 - アクティベート日 + ID発行日 状態 {state.data.participants.length === 0 ? ( - - 該当データがありません + + 該当する利用者が見つかりません ) : ( state.data.participants.map((p) => ( - + setSelectedParticipantId(p.id)} + > {p.id} {p.fullName} {p.nickname} @@ -126,11 +205,20 @@ export default function ParticipantsPage() { -
+
- 全 {state.data.pagination.total} 件中 {state.data.participants.length} 件表示 + 全 {state.data.pagination.total} 件 ・ {page} / {totalPages} ページ -
+
+ - - {page} / {totalPages} - +
)} + + { + if (!open) setSelectedParticipantId(null); + }} + />
); } diff --git a/apps/admin/src/app/(authed)/pre-registrations/page.tsx b/apps/admin/src/app/(authed)/pre-registrations/page.tsx index dd4b607..d7f21da 100644 --- a/apps/admin/src/app/(authed)/pre-registrations/page.tsx +++ b/apps/admin/src/app/(authed)/pre-registrations/page.tsx @@ -37,7 +37,6 @@ import { SelectTrigger, SelectValue, } from '@tecnova/ui/components/select'; -import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, @@ -46,8 +45,11 @@ import { TableHeader, TableRow, } from '@tecnova/ui/components/table'; -import { ApiError, apiErrorMessage, apiFetch, apiJson } from '@tecnova/ui/lib/api-client'; +import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; +import { ApiError, apiFetch, apiJson } from '@tecnova/ui/lib/api-client'; +import { toastError, toastSuccess } from '@tecnova/ui/lib/toast'; import { type FormEvent, useCallback, useEffect, useState } from 'react'; +import { PageHeader } from '@/components/page-header'; type State = | { kind: 'loading' } @@ -72,7 +74,10 @@ export default function PreRegistrationsPage() { const data = await apiJson('/api/pre-registrations'); setState({ kind: 'ok', preRegistrations: data.preRegistrations }); } catch (e) { - setState({ kind: 'error', message: apiErrorMessage(e) }); + setState({ + kind: 'error', + message: e instanceof Error ? e.message : String(e), + }); } }, []); @@ -94,17 +99,15 @@ export default function PreRegistrationsPage() { } return ( -
-
-

事前登録管理

-
+
+ - {state.kind === 'loading' && } + {state.kind === 'loading' && } {state.kind === 'error' && ( - エラー + 読み込めませんでした {state.message} )} @@ -125,8 +128,8 @@ export default function PreRegistrationsPage() { {state.preRegistrations.length === 0 ? ( - - 未アクティベートの事前登録はありません + + ID未発行の事前登録はありません ) : ( @@ -150,23 +153,30 @@ function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise(DEFAULT_GRADE); const [registeredAt, setRegisteredAt] = useState(todayInJst()); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); const submit = async (e: FormEvent) => { e.preventDefault(); if (busy) return; setBusy(true); - setError(null); try { - const body: CreatePreRegistrationRequest = { fullName, nickname, grade, registeredAt }; - await apiJson('/api/pre-registrations', { method: 'POST', body }); + const body: CreatePreRegistrationRequest = { + fullName, + nickname, + grade, + registeredAt, + }; + const created = await apiJson('/api/pre-registrations', { + method: 'POST', + body, + }); + toastSuccess(`${created.preRegistrationId} を追加しました`); setFullName(''); setNickname(''); setGrade(DEFAULT_GRADE); setRegisteredAt(todayInJst()); await onCreated(); } catch (e) { - setError(apiErrorMessage(e)); + toastError(e, '事前登録を追加できませんでした'); } finally { setBusy(false); } @@ -232,12 +242,6 @@ function CreatePreRegistrationForm({ onCreated }: { onCreated: () => Promise - {error && ( - - 追加できませんでした - {error} - - )} @@ -252,13 +256,11 @@ function PreRegistrationRow({ onDeleted: () => Promise; }) { const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); const deleteDescription = `${item.preRegistrationId}(${item.fullName} / ${item.nickname})を削除します。この操作は取り消せません。`; const remove = async () => { if (busy) return; setBusy(true); - setError(null); try { // 204 を返すので apiJson ではなく apiFetch を使う。 const r = await apiFetch( @@ -271,9 +273,10 @@ function PreRegistrationRow({ const body = await r.json().catch(() => ({})); throw new ApiError(r.status, body); } + toastSuccess(`${item.preRegistrationId} を削除しました`); await onDeleted(); } catch (e) { - setError(apiErrorMessage(e)); + toastError(e, '削除できませんでした'); } finally { setBusy(false); } @@ -287,32 +290,25 @@ function PreRegistrationRow({ {item.grade} {item.registeredAt} -
- - - - - - - 事前登録を削除しますか? - {deleteDescription} - - - キャンセル - - 削除 - - - - - {error && ( - - {error} - - )} -
+ + + + + + + 事前登録を削除しますか? + {deleteDescription} + + + キャンセル + + 削除 + + + +
); diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index 9da87d3..f55f8c5 100644 --- a/apps/admin/src/app/login/page.tsx +++ b/apps/admin/src/app/login/page.tsx @@ -41,11 +41,16 @@ export default function LoginPage() { }; return ( -
- - - テクノバ管理画面 - 許可リストに登録されたメンターのみログインできます +
+ + +

+ テクノバながさき 運営管理 +

+ 管理画面にログイン + + 許可リストに登録された管理者のみログインできます。 Google アカウントで認証してください。 +
{error && ( diff --git a/apps/admin/src/components/app-shell.tsx b/apps/admin/src/components/app-shell.tsx index d6d1a89..7be7dbf 100644 --- a/apps/admin/src/components/app-shell.tsx +++ b/apps/admin/src/components/app-shell.tsx @@ -1,7 +1,23 @@ 'use client'; +import { + IconChevronDown, + IconClipboardList, + IconLayoutDashboard, + IconLogout, + IconUserShield, + IconUsers, +} from '@tabler/icons-react'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@tecnova/ui/components/dropdown-menu'; import { useMe } from '@tecnova/ui/components/me-provider'; import { Separator } from '@tecnova/ui/components/separator'; import { cn } from '@tecnova/ui/lib/utils'; @@ -12,6 +28,7 @@ import { authClient } from '@/lib/auth-client'; interface NavItem { href: string; label: string; + Icon: typeof IconLayoutDashboard; } interface Props { @@ -29,32 +46,64 @@ export function AppShell({ children }: Props) { }; const navItems: NavItem[] = [ - { href: '/', label: 'ダッシュボード' }, - { href: '/participants', label: '参加者一覧' }, + { href: '/', label: 'ダッシュボード', Icon: IconLayoutDashboard }, + { href: '/participants', label: '利用者一覧', Icon: IconUsers }, ...(me.mentor.role === 'admin' ? [ - { href: '/pre-registrations', label: '事前登録管理' }, - { href: '/mentors', label: 'メンター管理' }, + { + href: '/pre-registrations', + label: '事前登録管理', + Icon: IconClipboardList, + }, + { href: '/mentors', label: '管理者一覧', Icon: IconUserShield }, ] : []), ]; return (
-
+

テクノバ管理画面

-
- - {me.user.name} - {me.mentor.role} - - -
+ + + + + + +
+
+ 管理者名 + {me.mentor.name} +
+
+ Googleアカウント名 + {me.user.name} +
+
+ メールアドレス + {me.user.email} +
+ + {me.mentor.role} + +
+
+ + + + ログアウト + +
+
-
+ +
+ ); } diff --git a/apps/api/src/lib/checkin.ts b/apps/api/src/lib/checkin.ts index 5c1da16..81fbabc 100644 --- a/apps/api/src/lib/checkin.ts +++ b/apps/api/src/lib/checkin.ts @@ -40,6 +40,9 @@ export const parseSheetRows = (rows: string[][]): PreRegRow[] => })) .filter((r) => r.preRegistrationId); +export const isActivatedPreRegRow = (row: PreRegRow): boolean => + row.activated || row.internalId.trim() !== '' || row.activatedAt.trim() !== ''; + export const fetchPreRegisteredList = async ( encodedKey: string, spreadsheetId: string, @@ -48,7 +51,7 @@ export const fetchPreRegisteredList = async ( > => { const raw = await fetchSheetRows(encodedKey, spreadsheetId, SHEET_RANGE); return parseSheetRows(raw) - .filter((r) => !r.activated) + .filter((r) => !isActivatedPreRegRow(r)) .sort((a, b) => b.registeredAt.localeCompare(a.registeredAt)) .map(({ preRegistrationId, fullName, nickname, grade, registeredAt }) => ({ preRegistrationId, @@ -164,7 +167,7 @@ export const activatePreRegistered = async ({ if (!target) { throw new CheckinError('NOT_FOUND', `pre-registration ${preRegistrationId} not found`); } - if (target.activated) { + if (isActivatedPreRegRow(target)) { throw new CheckinError('ALREADY_ACTIVATED', `${preRegistrationId} is already activated`); } diff --git a/apps/api/src/lib/pre-registrations.ts b/apps/api/src/lib/pre-registrations.ts index 195eec2..9bebbf0 100644 --- a/apps/api/src/lib/pre-registrations.ts +++ b/apps/api/src/lib/pre-registrations.ts @@ -1,10 +1,11 @@ import { appendSheetRows, clearSheetRange, fetchSheetRows } from '@tecnova/shared/google-sheets'; import type { + ActivatedPreRegistrationItem, CreatePreRegistrationRequest, PreRegistrationItem, PreRegistrationsListResponse, } from '@tecnova/shared/schemas'; -import { type PreRegRow, parseSheetRows, SHEET_RANGE } from './checkin'; +import { isActivatedPreRegRow, type PreRegRow, parseSheetRows, SHEET_RANGE } from './checkin'; export type PreRegistrationErrorCode = 'NOT_FOUND' | 'ALREADY_ACTIVATED' | 'SHEETS_WRITE_FAILED'; @@ -26,16 +27,30 @@ const toItem = (row: PreRegRow): PreRegistrationItem => ({ registeredAt: row.registeredAt, }); +const toActivatedItem = (row: PreRegRow): ActivatedPreRegistrationItem => ({ + ...toItem(row), + internalId: row.internalId, + activatedAt: row.activatedAt, +}); + export const fetchPreRegistrationsList = async ( encodedKey: string, spreadsheetId: string, ): Promise => { const raw = await fetchSheetRows(encodedKey, spreadsheetId, SHEET_RANGE); - const items = parseSheetRows(raw) - .filter((r) => !r.activated) + const rows = parseSheetRows(raw); + const items = rows + .filter((r) => !isActivatedPreRegRow(r)) .sort((a, b) => b.registeredAt.localeCompare(a.registeredAt)) .map(toItem); - return { preRegistrations: items }; + const activatedItems = rows + .filter(isActivatedPreRegRow) + .sort( + (a, b) => + b.activatedAt.localeCompare(a.activatedAt) || b.registeredAt.localeCompare(a.registeredAt), + ) + .map(toActivatedItem); + return { preRegistrations: items, activatedPreRegistrations: activatedItems }; }; // `PRE-{year}-{NNNN}` 形式。year は JST 現在年、連番は当該年プレフィックスで @@ -105,7 +120,7 @@ export const deletePreRegistration = async ( if (!target) { throw new PreRegistrationError('NOT_FOUND', `pre-registration ${preRegistrationId} not found`); } - if (target.activated) { + if (isActivatedPreRegRow(target)) { throw new PreRegistrationError( 'ALREADY_ACTIVATED', `${preRegistrationId} is already activated; refusing to delete`, diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 76e66bd..e8d7b35 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -153,8 +153,14 @@ export const preRegistrationItemSchema = z.object({ registeredAt: z.string(), // 'YYYY-MM-DD' (JST) }); +export const activatedPreRegistrationItemSchema = preRegistrationItemSchema.extend({ + internalId: z.string(), + activatedAt: z.string(), +}); + export const preRegistrationsListResponseSchema = z.object({ preRegistrations: z.array(preRegistrationItemSchema), + activatedPreRegistrations: z.array(activatedPreRegistrationItemSchema), }); // preRegistrationId は backend が `PRE-{year}-{NNNN}` で自動採番するため、 @@ -180,5 +186,6 @@ export type MentorsListResponse = z.infer; export type CreateMentorRequest = z.infer; export type UpdateMentorRequest = z.infer; export type PreRegistrationItem = z.infer; +export type ActivatedPreRegistrationItem = z.infer; export type PreRegistrationsListResponse = z.infer; export type CreatePreRegistrationRequest = z.infer; diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx new file mode 100644 index 0000000..8e227d3 --- /dev/null +++ b/packages/ui/src/components/collapsible.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { Collapsible as CollapsiblePrimitive } from 'radix-ui'; + +function Collapsible({ ...props }: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Collapsible, CollapsibleContent, CollapsibleTrigger };