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,
};