From 98dfdf77490c74c11be510b16940737d008126af Mon Sep 17 00:00:00 2001 From: f0reachARR Date: Fri, 24 Apr 2026 17:07:02 +0900 Subject: [PATCH] admin multiselect --- .../editions/[id]/participations/page.tsx | 93 +++++---- .../admin/UniversityMultiSelect.tsx | 191 ++++++++++++++++++ .../features/admin/participations/hooks.ts | 104 +++++++++- 3 files changed, 353 insertions(+), 35 deletions(-) create mode 100644 apps/frontend/components/admin/UniversityMultiSelect.tsx diff --git a/apps/frontend/app/(admin)/admin/editions/[id]/participations/page.tsx b/apps/frontend/app/(admin)/admin/editions/[id]/participations/page.tsx index 732da53..f0a07d3 100644 --- a/apps/frontend/app/(admin)/admin/editions/[id]/participations/page.tsx +++ b/apps/frontend/app/(admin)/admin/editions/[id]/participations/page.tsx @@ -1,9 +1,9 @@ 'use client'; import type { ColumnDef } from '@tanstack/react-table'; -import { CheckIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'; +import { CheckIcon, PencilIcon, Trash2Icon } from 'lucide-react'; import { use } from 'react'; -import { UniversitySelect } from '@/components/admin/UniversitySelect'; +import { UniversityMultiSelect } from '@/components/admin/UniversityMultiSelect'; import { ConfirmDialog } from '@/components/common/ConfirmDialog'; import { DataTable } from '@/components/common/DataTable'; import { Button } from '@/components/ui/button'; @@ -16,17 +16,17 @@ import { export default function AdminParticipationsPage({ params }: { params: Promise<{ id: string }> }) { const { id: editionId } = use(params); const { - selectedUniversityId, - setSelectedUniversityId, - teamName, - setTeamName, + draftRows, + addDraftRows, + updateDraftTeamName, + removeDraftRow, editingId, setEditingId, editingTeamName, setEditingTeamName, data, isLoading, - createMutation, + createManyMutation, updateMutation, deleteMutation, } = useAdminParticipationsPage(editionId); @@ -92,35 +92,60 @@ export default function AdminParticipationsPage({ params }: { params: Promise<{

出場登録管理

- {/* Add participation */} -
-
- 大学 - setSelectedUniversityId(id)} - /> +
+
+
+ 大学 + +
+
-
- - setTeamName(e.target.value)} - className='w-40' - /> + +
+ {draftRows.length === 0 ? ( +
+ 登録する大学を選択してください +
+ ) : ( +
+
+ 大学名 + チーム名(任意) + 操作 +
+ {draftRows.map((row) => ( +
+
{row.universityName}
+ updateDraftTeamName(row.id, e.target.value)} + disabled={createManyMutation.isPending} + /> + +
+ ))} +
+ )}
- -
+
void; + disabled?: boolean; +}; + +export function UniversityMultiSelect({ onAdd, disabled = false }: UniversityMultiSelectProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUniversities, setSelectedUniversities] = useState([]); + const loadMoreRef = useRef(null); + + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ + queryKey: queryKeys.admin.universities({ q: searchQuery, pageSize: UNIVERSITY_PAGE_SIZE }), + queryFn: async ({ pageParam = 1 }) => { + const result = await apiClient.GET('/api/admin/universities', { + params: { + query: { + page: pageParam, + pageSize: UNIVERSITY_PAGE_SIZE, + q: searchQuery || undefined, + }, + }, + }); + return throwIfError(result); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.pagination.hasNext ? lastPage.pagination.page + 1 : undefined, + }); + + const universities = useMemo(() => { + const allUniversities = (data?.pages ?? []).flatMap((page) => page.data) as UniversityOption[]; + const seenIds = new Set(); + return allUniversities.filter((university) => { + if (seenIds.has(university.id)) { + return false; + } + seenIds.add(university.id); + return true; + }); + }, [data?.pages]); + + const selectedIds = useMemo( + () => new Set(selectedUniversities.map((university) => university.id)), + [selectedUniversities], + ); + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage) { + return; + } + + const observer = new IntersectionObserver((entries) => { + const [entry] = entries; + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + observer.observe(node); + + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const toggleUniversity = (university: UniversityOption) => { + setSelectedUniversities((current) => { + if (current.some((item) => item.id === university.id)) { + return current.filter((item) => item.id !== university.id); + } + return [...current, university]; + }); + }; + + const handleAdd = () => { + if (selectedUniversities.length === 0) { + return; + } + + onAdd(selectedUniversities); + setSelectedUniversities([]); + setSearchQuery(''); + setOpen(false); + }; + + return ( + + } + > + + {selectedUniversities.length === 0 + ? '大学を複数選択...' + : `${selectedUniversities.length}校を選択中`} + + + + + + + + {isLoading ? ( +
+ + 読み込み中... +
+ ) : ( + <> + 見つかりません + + {universities.map((university) => { + const selected = selectedIds.has(university.id); + return ( + toggleUniversity(university)} + > + + {selected && } + + {university.name} + + ); + })} + +
+ {hasNextPage && !isFetchingNextPage && ( +
+ +
+ )} + {isFetchingNextPage && ( +
+ + さらに読み込み中... +
+ )} + + )} + +
+ + {selectedUniversities.length}校選択 + + +
+ + + + ); +} diff --git a/apps/frontend/features/admin/participations/hooks.ts b/apps/frontend/features/admin/participations/hooks.ts index 6407bae..dc8c7e8 100644 --- a/apps/frontend/features/admin/participations/hooks.ts +++ b/apps/frontend/features/admin/participations/hooks.ts @@ -6,6 +6,14 @@ import { invalidateAdminParticipationsQueries } from '@/lib/query/invalidation'; import { queryKeys } from '@/lib/query/keys'; import { getApiErrorMessage } from '@/lib/utils/errors'; +const normalizeTeamName = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; +}; + +const participationKey = (universityId: string, teamName: string | null | undefined): string => + `${universityId}:${normalizeTeamName(teamName) ?? ''}`; + export type Participation = { id: string; editionId: string; @@ -15,10 +23,18 @@ export type Participation = { createdAt: unknown; }; +export type ParticipationDraft = { + id: string; + universityId: string; + universityName: string; + teamName: string; +}; + export function useAdminParticipationsPage(editionId: string) { const queryClient = useQueryClient(); const [selectedUniversityId, setSelectedUniversityId] = useState(''); const [teamName, setTeamName] = useState(''); + const [draftRows, setDraftRows] = useState([]); const [editingId, setEditingId] = useState(null); const [editingTeamName, setEditingTeamName] = useState(''); @@ -26,7 +42,7 @@ export function useAdminParticipationsPage(editionId: string) { queryKey: queryKeys.admin.participations(editionId, {}), queryFn: async () => { const result = await apiClient.GET('/api/admin/editions/{id}/participations', { - params: { path: { id: editionId } }, + params: { path: { id: editionId }, query: { pageSize: 100 } }, }); return throwIfError(result); }, @@ -49,6 +65,62 @@ export function useAdminParticipationsPage(editionId: string) { onError: (err) => toast.error(getApiErrorMessage(err)), }); + const createManyMutation = useMutation({ + mutationFn: async () => { + const existingKeys = new Set( + ((data?.data ?? []) as Participation[]).map((participation) => + participationKey(participation.universityId, participation.teamName), + ), + ); + const acceptedKeys = new Set(existingKeys); + const failedRows: ParticipationDraft[] = []; + let createdCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const row of draftRows) { + const normalizedTeamName = normalizeTeamName(row.teamName); + const key = participationKey(row.universityId, normalizedTeamName); + + if (acceptedKeys.has(key)) { + skippedCount += 1; + continue; + } + + try { + const result = await apiClient.POST('/api/admin/editions/{id}/participations', { + params: { path: { id: editionId } }, + body: { + universityId: row.universityId, + teamName: normalizedTeamName ?? undefined, + }, + }); + throwIfError(result); + acceptedKeys.add(key); + createdCount += 1; + } catch { + failedRows.push(row); + failedCount += 1; + } + } + + return { createdCount, skippedCount, failedCount, failedRows }; + }, + onSuccess: async ({ createdCount, skippedCount, failedCount, failedRows }) => { + await invalidateAdminParticipationsQueries(queryClient, editionId); + setDraftRows(failedRows); + + const message = `登録成功 ${createdCount}件 / スキップ ${skippedCount}件 / 失敗 ${failedCount}件`; + if (failedCount > 0) { + toast.error(message); + return; + } + + toast.success(message); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + const updateMutation = useMutation({ mutationFn: async ({ id, teamName }: { id: string; teamName: string | null }) => { const result = await apiClient.PUT('/api/admin/participations/{id}', { @@ -81,11 +153,40 @@ export function useAdminParticipationsPage(editionId: string) { onError: (err) => toast.error(getApiErrorMessage(err)), }); + const addDraftRows = ( + universities: Array<{ id: string; name: string }>, + createId: () => string = crypto.randomUUID, + ) => { + setDraftRows((current) => [ + ...current, + ...universities.map((university) => ({ + id: createId(), + universityId: university.id, + universityName: university.name, + teamName: '', + })), + ]); + }; + + const updateDraftTeamName = (id: string, value: string) => { + setDraftRows((current) => + current.map((row) => (row.id === id ? { ...row, teamName: value } : row)), + ); + }; + + const removeDraftRow = (id: string) => { + setDraftRows((current) => current.filter((row) => row.id !== id)); + }; + return { selectedUniversityId, setSelectedUniversityId, teamName, setTeamName, + draftRows, + addDraftRows, + updateDraftTeamName, + removeDraftRow, editingId, setEditingId, editingTeamName, @@ -93,6 +194,7 @@ export function useAdminParticipationsPage(editionId: string) { data, isLoading, createMutation, + createManyMutation, updateMutation, deleteMutation, };