Skip to content
12 changes: 12 additions & 0 deletions src/apis/requiredFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { RequiredFieldsDto } from 'types/DTO/requiredFieldsDto';
import apiClient from './apiClient';

export const getRequiredFields = async (contestId: number): Promise<RequiredFieldsDto> => {
const { data } = await apiClient.get(`/contests/${contestId}/team-detail-template`);
return data;
};

export const putRequiredFields = async (contestId: number, payload: RequiredFieldsDto) => {
const { data } = await apiClient.put(`/contests/${contestId}/team-detail-template`, payload);
return data;
};
32 changes: 32 additions & 0 deletions src/components/ui/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FaCheck } from 'react-icons/fa';

interface CheckboxProps {
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
ariaLabel?: string;
}

const Checkbox = ({ checked, onChange, disabled = false, ariaLabel }: CheckboxProps) => {
return (
<div className="w-fit">
<input
id={ariaLabel}
aria-label={ariaLabel}
type="checkbox"
checked={checked}
onChange={(e) => onChange?.(e.target.checked)}
disabled={disabled}
className="peer sr-only"
/>
<label
htmlFor={ariaLabel}
className="border-mainBlue peer-checked:bg-mainBlue flex h-5.5 w-5.5 items-center justify-center rounded-sm border p-1 hover:cursor-pointer"
>
{checked && <FaCheck fill="white" size={14} />}
</label>
</div>
);
};

export default Checkbox;
31 changes: 31 additions & 0 deletions src/constants/requiredFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { RequiredFieldsDto } from 'types/DTO/requiredFieldsDto';

export const defaultRequiredFields: RequiredFieldsDto = {
division: 'OPTIONAL',
projectName: 'OPTIONAL',
teamName: 'OPTIONAL',
leader: 'OPTIONAL',
teamMembers: 'OPTIONAL',
professor: 'OPTIONAL',
githubPath: 'OPTIONAL',
youtubePath: 'OPTIONAL',
productionPath: 'OPTIONAL',
overview: 'OPTIONAL',
poster: 'OPTIONAL',
images: 'OPTIONAL',
};

export const labelByField: Record<string, string> = {
division: '분과',
projectName: '프로젝트명',
teamName: '팀명',
leader: '팀장',
teamMembers: '팀원',
professor: '지도교수',
githubPath: 'GitHub 링크',
youtubePath: 'Youtube 링크',
productionPath: '배포 링크',
overview: '프로젝트 개요',
poster: '포스터',
images: '이미지',
};
48 changes: 48 additions & 0 deletions src/hooks/useRequiredFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { defaultRequiredFields } from 'constants/requiredFields';
import { RequiredFieldsDto, RequiredFieldsType } from 'types/DTO/requiredFieldsDto';
import { useToast } from './useToast';
import { getRequiredFields, putRequiredFields } from 'apis/requiredFields';

export const useRequiredFields = (contestId: number) => {
const [fieldsSetting, setFieldsSetting] = useState<RequiredFieldsDto>(defaultRequiredFields);
const toast = useToast();
const queryClient = useQueryClient();

const { data: requiredFields } = useQuery({
queryKey: ['requiredFields', contestId],
queryFn: () => getRequiredFields(contestId),
enabled: !!contestId,
});
const updateRequiredFields = useMutation({
mutationKey: [`updateRequiredFields`],
mutationFn: (payload: RequiredFieldsDto) => putRequiredFields(contestId, payload),
});

useEffect(() => {
if (requiredFields) {
setFieldsSetting(requiredFields);
}
}, [requiredFields]);

const toggleField = (key: string, value: RequiredFieldsType) => {
setFieldsSetting((prev) => ({ ...prev, [key]: value }));
};

const handleSave = async () => {
if (!fieldsSetting) return;

updateRequiredFields.mutate(fieldsSetting, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['requiredFields', contestId] });
toast('필수 항목 설정이 저장되었습니다.', 'success');
},
onError: () => {
toast('필수 항목 설정 저장에 실패했습니다.', 'error');
},
});
};

return { fieldsSetting, isLoading: updateRequiredFields.isPending, toggleField, handleSave };
};
40 changes: 40 additions & 0 deletions src/pages/admin/required-field/RequiredFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import Checkbox from '@components/ui/Checkbox';
import { RequiredFieldsDto, RequiredFieldsType } from 'types/DTO/requiredFieldsDto';
import { labelByField } from 'constants/requiredFields';
import { cn } from 'utils/classname';

interface RequiredFieldsProps {
fields: RequiredFieldsDto;
onToggle: (key: string, value: RequiredFieldsType) => void;
className?: string;
}

const RequiredFields: React.FC<RequiredFieldsProps> = ({ fields, onToggle, className = '' }) => {
return (
<div className={cn('rounded-lg border border-gray-200 bg-white shadow-sm', className)}>
<div className="w-full text-sm">
<div className="flex justify-between bg-gray-100 px-7 py-5 font-medium">
<div>필드명</div>
<div className="w-16 text-center">필수</div>
</div>
<div>
{Object.entries(fields).map(([key, value]) => (
<div key={key} className={`flex items-center justify-between border-t px-7 py-5 hover:bg-gray-50`}>
<div>{labelByField[key]}</div>
<div className="flex w-16 justify-center">
<Checkbox
checked={value === 'REQUIRED'}
onChange={(checked) => onToggle(key, checked ? 'REQUIRED' : 'OPTIONAL')}
ariaLabel={`${labelByField[key]} 필수 여부`}
/>
</div>
</div>
))}
</div>
</div>
</div>
);
};

export default RequiredFields;
29 changes: 29 additions & 0 deletions src/pages/admin/required-field/RequiredFieldsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import RequiredFields from '@pages/admin/required-field/RequiredFields';
import { Button } from '@components/ui/button';
import { useRequiredFields } from 'hooks/useRequiredFields';
import { useParams } from 'react-router-dom';

const RequiredFieldsPage: React.FC = () => {
const { contestId } = useParams();
const { fieldsSetting, isLoading, toggleField, handleSave } = useRequiredFields(Number(contestId ?? 0));

return (
<div className="flex flex-col gap-8">
<header className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-title mb-2 font-bold">필수 항목 설정</h2>
<p className="text-sm text-gray-500">프로젝트 생성/수정 폼의 필수 항목을 설정합니다.</p>
</div>
<div className="flex items-center gap-3">
<Button className="bg-mainBlue hover:bg-blue-600" onClick={handleSave} disabled={isLoading}>
{isLoading ? '저장 중...' : '저장'}
</Button>
</div>
</header>
<RequiredFields fields={fieldsSetting} onToggle={toggleField} />
</div>
);
};

export default RequiredFieldsPage;
3 changes: 2 additions & 1 deletion src/route/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import AdminLayout from '@layout/admin/AdminLayout';
import AdminContestLayout from '@layout/admin/contest/AdminContestLayout';
import FullContainer from '@layout/FullContainer';
import TeamOrderAdminPage from '@pages/admin/team-order/TeamOrderAdminPage';
import RequiredFieldsPage from '@pages/admin/required-field/RequiredFieldsPage';

const AppRoutes = () =>
createBrowserRouter([
Expand Down Expand Up @@ -61,7 +62,7 @@ const AppRoutes = () =>
{ path: 'projects', element: <div>프로젝트 관리</div> },
{ path: 'team-order', element: <TeamOrderAdminPage /> },
{ path: 'awards', element: <div>수상 관리</div> },
{ path: 'required-fields', element: <div>필수 항목 설정</div> },
{ path: 'required-fields', element: <RequiredFieldsPage /> },
// 대회
{ path: 'settings', element: <div>대회 관리</div> },
{ path: 'departments', element: <div>분과 관리</div> },
Expand Down
16 changes: 16 additions & 0 deletions src/types/DTO/requiredFieldsDto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type RequiredFieldsType = 'REQUIRED' | 'OPTIONAL';

export interface RequiredFieldsDto {
division: RequiredFieldsType;
projectName: RequiredFieldsType;
teamName: RequiredFieldsType;
leader: RequiredFieldsType;
teamMembers: RequiredFieldsType;
professor: RequiredFieldsType;
githubPath: RequiredFieldsType;
youtubePath: RequiredFieldsType;
productionPath: RequiredFieldsType;
overview: RequiredFieldsType;
poster: RequiredFieldsType;
images: RequiredFieldsType;
}