Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/apis/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ContestBulkAddTeamsResponseDto,
GroupedContestResponseDto,
ProjectsAdminResponseDto,
TeamCustomSortData,
TeamSortOption,
} from 'types/DTO';
import { TeamListItemResponseDto } from 'types/DTO/teams/teamListDto';

Expand Down Expand Up @@ -81,6 +83,21 @@ export const postBulkAddTeams = async (
return res.data;
};

export const getSortStatus = async (contestId: number): Promise<TeamSortOption> => {
const res = await apiClient.get(`/contests/${contestId}/sort`);
return res.data.currentMode as TeamSortOption;
};

export const putTeamSort = async (contestId: number, mode: string) => {
const res = await apiClient.put(`/contests/${contestId}/sort`, { mode });
return res.data;
};

export const putTeamCustomSort = async (contestId: number, payload: TeamCustomSortData[]) => {
const res = await apiClient.put(`/contests/${contestId}/sort/custom`, payload);
return res.data;
};

export const getProjectsAdmin = async (contestId: number): Promise<ProjectsAdminResponseDto[]> => {
const res = await apiClient.get<ProjectsAdminResponseDto[]>(`/contests/${contestId}/submissions`);
return res.data;
Expand Down
24 changes: 1 addition & 23 deletions src/apis/team.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import { TeamListItemResponseDto } from '../types/DTO/teams/teamListDto';
import { SubmissionStatusResponseDto } from '../types/DTO/teams/submissionStatusDto';
import { PatchAwardRequestDto, PatchCustomOrderRequestDto, GetTeamAwardsResponseDto } from 'types/DTO';
import { PatchAwardRequestDto, GetTeamAwardsResponseDto } from 'types/DTO';
import apiClient from './apiClient';
import { API_BASE_URL } from '@constants/env';

export type SortOption = 'RANDOM' | 'ASC' | 'CUSTOM';
export const sortOptions: { label: string; value: SortOption }[] = [
{ label: '랜덤', value: 'RANDOM' },
{ label: '오름차순', value: 'ASC' },
{ label: '직접 설정', value: 'CUSTOM' },
];

export const getAllTeams = async (contestId: number): Promise<TeamListItemResponseDto[]> => {
const res = await apiClient.get(`/contests/${contestId}/teams`);
return res.data;
Expand Down Expand Up @@ -43,21 +36,6 @@ export const deleteTeam = async (teamId: number) => {
return res.data;
};

export const patchSortTeam = async (mode: string) => {
const res = await apiClient.patch('/teams/sort', { mode: mode });
return res.data;
};

export const getSortStatus = async (): Promise<SortOption> => {
const res = await apiClient.get('/teams/sort');
return res.data.currentMode as SortOption;
};

export const patchCustomSortTeam = async (payload: PatchCustomOrderRequestDto) => {
const res = await apiClient.patch('/teams/sort/custom', payload);
return res.data;
};

export const getTeamAwards = async (teamId: number): Promise<GetTeamAwardsResponseDto> => {
const res = await apiClient.get(`admin/teams/${teamId}/awards`);
return res.data;
Expand Down
2 changes: 1 addition & 1 deletion src/constants/adminContestLayoutSidebarData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const adminContestSidebarData = [
title: '프로젝트',
links: [
{ to: 'projects', label: '프로젝트 관리' },
{ to: 'team-order', label: '정렬 관리' },
{ to: 'sort', label: '정렬 관리' },
{ to: 'awards', label: '수상 관리' },
{ to: 'required-fields', label: '필수 항목 설정' },
],
Expand Down
8 changes: 8 additions & 0 deletions src/constants/contest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { TeamSortOption } from 'types/DTO';

export const contestCreateSteps = ['대회 생성', '팀·참여자 설정', '필수 항목 설정'];

export const XLSX_MIME_TYPE = `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`;

export const sortOptions: { label: string; value: TeamSortOption }[] = [
{ label: '랜덤', value: 'RANDOM' },
{ label: '오름차순', value: 'ASC' },
{ label: '직접 설정', value: 'CUSTOM' },
];
5 changes: 3 additions & 2 deletions src/hooks/useAwardAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useState, useEffect } from 'react';
import useTeamList from 'hooks/useTeamList';
import { AwardDto } from 'types/DTO/awardsDto';
import { useQuery } from '@tanstack/react-query';
import { contestTeamOption } from 'queries/contest';

interface AwardViewState {
selectedTeamId?: number;
awards: AwardDto[];
}

export const useAwardViewAdmin = (contestId: number) => {
const { data: teamList } = useTeamList(contestId);
const { data: teamList } = useQuery(contestTeamOption(contestId));

const [viewState, setViewState] = useState<AwardViewState>({
selectedTeamId: undefined,
Expand Down
16 changes: 0 additions & 16 deletions src/hooks/useTeamList.ts

This file was deleted.

5 changes: 3 additions & 2 deletions src/pages/admin/award-manage/AwardManagePage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { useAwardViewAdmin } from 'hooks/useAwardAdmin';
import { useContestIdOrRedirect } from 'hooks/useId';
import useTeamList from 'hooks/useTeamList';
import { AdminCardRow, AdminHeader } from '@components/admin';
import AwardTag from '@components/AwardTag';
import AwardEditForm from './AwardEditForm';
import { TeamListItemResponseDto } from 'types/DTO/teams/teamListDto';
import { twMerge } from 'tailwind-merge';
import Spinner from '@components/Spinner';
import { useQuery } from '@tanstack/react-query';
import { contestTeamOption } from 'queries/contest';

const AwardManagePage = () => {
const contestId = useContestIdOrRedirect();
const viewAdmin = useAwardViewAdmin(contestId);
const { data: teamList, isLoading, error } = useTeamList(contestId);
const { data: teamList, isLoading, error } = useQuery(contestTeamOption(contestId));

return (
<div className="flex w-full flex-col">
Expand Down
121 changes: 121 additions & 0 deletions src/pages/admin/sort/CustomOrderSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useState, useEffect, Fragment } from 'react';
import { DndContext, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { PiDotsSixVerticalBold } from 'react-icons/pi';
import { AdminActionButton } from '@components/admin';
import AwardTag from '@components/AwardTag';
import { cn } from 'utils/classname';
import { TeamListItemResponseDto } from 'types/DTO/teams/teamListDto';
import queryClient from 'stores/queryClient';
import { useToast } from 'hooks/useToast';
import { TeamCustomSortData } from 'types/DTO';
import { contestTeamOption } from 'queries/contest';
import { putTeamCustomSort } from 'apis/contest';
import { useContestIdOrRedirect } from 'hooks/useId';

const CustomOrderSection = () => {
const contestId = useContestIdOrRedirect();
const [localTeams, setLocalTeams] = useState<TeamListItemResponseDto[]>([]);
const toast = useToast();

const { data: teamList } = useSuspenseQuery(contestTeamOption(contestId));
const customSortMutation = useMutation({
mutationKey: ['saveCustomSort'],
mutationFn: (payload: TeamCustomSortData[]) => putTeamCustomSort(contestId, payload),
});

useEffect(() => {
if (teamList) setLocalTeams([...teamList]);
}, [teamList]);

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;

setLocalTeams((teams) => {
const from = teams.findIndex((t) => t.teamId === active.id);
const to = teams.findIndex((t) => t.teamId === over.id);
return arrayMove(teams, from, to);
});
};

const handleReset = () => {
if (teamList) setLocalTeams([...teamList]);
};

const handleSave = () => {
customSortMutation.mutate(
localTeams.map((team, i) => ({ teamId: team.teamId, itemOrder: i + 1 })),
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
toast('정렬이 저장되었어요', 'success');
},
onError: () => toast('정렬 저장에 실패했어요'),
},
);
};

return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border-2 px-5 py-4">
<div className="mb-4 flex items-center gap-3">
<h2 className="text-xl font-semibold">직접 설정</h2>
<span className="text-midGray text-xs font-normal">팀명, 프로젝트명, 수상 정보</span>
</div>
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={localTeams.map((t) => t.teamId)}>
<div className="grid grid-cols-[max-content_max-content_max-content_150px_auto] gap-4">
{localTeams.map((team, index) => (
<Fragment key={team.teamId}>
<span className="flex items-center justify-center text-center">{index + 1}</span>
<TeamRow team={team} />
</Fragment>
))}
</div>
</SortableContext>
</DndContext>
</div>
<div className="ml-auto flex gap-4">
<AdminActionButton onClick={handleReset} variant="outline">
원래대로
</AdminActionButton>
<AdminActionButton onClick={handleSave}>저장하기</AdminActionButton>
</div>
</div>
);
};

const TeamRow = ({ team }: { team: TeamListItemResponseDto }) => {
const { teamName, projectName, awards } = team;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: team.teamId });
const style = { transform: CSS.Transform.toString(transform), transition };

return (
<span
ref={setNodeRef}
style={style}
className={cn(
'group hover:bg-whiteGray col-span-4 grid grid-cols-subgrid items-center gap-10 rounded-md p-3',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
)}
{...attributes}
{...listeners}
>
<span className="break-all">{teamName}</span>
<span className="break-all">{projectName}</span>
{awards.length > 0 ? (
awards.map((award) => (
<AwardTag key={award.awardName} awardName={award.awardName ?? ''} awardColor={award.awardColor ?? ''} />
))
) : (
<span />
)}
<PiDotsSixVerticalBold className="text-midGray ml-auto text-lg opacity-0 group-hover:opacity-100" />
</span>
);
};

export default CustomOrderSection;
27 changes: 27 additions & 0 deletions src/pages/admin/sort/SortManagePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { AdminHeader } from '@components/admin';
import { sortStatusOption } from 'queries/contest';
import { useContestIdOrRedirect } from 'hooks/useId';
import QueryWrapper from 'providers/QueryWrapper';
import SortSelect from './SortSelect';
import CustomOrderSection from './CustomOrderSection';

const SortManagePage = () => {
const contestId = useContestIdOrRedirect();
const { data: currentSortOption } = useQuery(sortStatusOption(contestId));

return (
<div className="flex flex-col gap-12">
<AdminHeader title="정렬 관리" description="프로젝트의 정렬 순서를 변경할 수 있습니다.">
<QueryWrapper loadingStyle="h-10 w-50 rounded-sm my-0" errorStyle="h-10">
<SortSelect />
</QueryWrapper>
</AdminHeader>
<QueryWrapper loadingStyle="h-100 rounded-sm my-0" errorStyle="h-100">
{currentSortOption === 'CUSTOM' && <CustomOrderSection />}
</QueryWrapper>
</div>
);
};

export default SortManagePage;
54 changes: 54 additions & 0 deletions src/pages/admin/sort/SortSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@components/ui/select';
import { sortOptions } from '@constants/contest';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { putTeamSort } from 'apis/contest';
import { useContestIdOrRedirect } from 'hooks/useId';
import { useToast } from 'hooks/useToast';
import { sortStatusOption } from 'queries/contest';

const SortSelect = () => {
const contestId = useContestIdOrRedirect();
const queryClient = useQueryClient();
const toast = useToast();

const { data: currentSortOption } = useSuspenseQuery(sortStatusOption(contestId));

const { mutate, isPending } = useMutation({
mutationKey: ['changeSort'],
mutationFn: (mode: string) => putTeamSort(contestId, mode),
});

const handleChange = (mode: string) => {
mutate(mode, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sortStatus'] });
queryClient.invalidateQueries({ queryKey: ['teams'] });
const label = sortOptions.find((option) => option.value === mode)?.label;
toast(`프로젝트가 ${label} 정렬로 변경되었어요`, 'success');
},
onError: () => {
toast('프로젝트 정렬 설정에 실패했어요', 'error');
},
});
};

return (
<Select onValueChange={handleChange} value={currentSortOption || sortOptions[0].value} disabled={isPending}>
<SelectTrigger
className="border-subGreen h-10 w-fit min-w-[200px] rounded-none border-0 border-b-2 shadow-none focus:ring-0 focus:ring-offset-0 focus:outline-none"
iconClassName="stroke-mainGreen opacity-100 h-5 w-5"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

export default SortSelect;
Loading