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
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export function HomeUniversitiesPageContent() {
await invalidate();
setName("");
setMaxChoiceCount("");
toast.success("협정 대학을 생성했습니다.");
toast.success("국내 대학을 생성했습니다.");
},
onError: () => toast.error("협정 대학 생성에 실패했습니다."),
onError: () => toast.error("국내 대학 생성에 실패했습니다."),
});

const updateMutation = useMutation({
Expand All @@ -46,18 +46,18 @@ export function HomeUniversitiesPageContent() {
onSuccess: async () => {
await invalidate();
setEditingId(null);
toast.success("협정 대학을 수정했습니다.");
toast.success("국내 대학을 수정했습니다.");
},
onError: () => toast.error("협정 대학 수정에 실패했습니다."),
onError: () => toast.error("국내 대학 수정에 실패했습니다."),
});

const deleteMutation = useMutation({
mutationFn: adminApi.deleteHomeUniversity,
onSuccess: async () => {
await invalidate();
toast.success("협정 대학을 삭제했습니다.");
toast.success("국내 대학을 삭제했습니다.");
},
onError: () => toast.error("협정 대학 삭제에 실패했습니다."),
onError: () => toast.error("국내 대학 삭제에 실패했습니다."),
});

const handleCreate = (e: FormEvent) => {
Expand Down Expand Up @@ -88,7 +88,7 @@ export function HomeUniversitiesPageContent() {
};

const handleDelete = (id: number, univName: string) => {
if (!window.confirm(`협정 대학 "${univName}"을 삭제할까요?`)) return;
if (!window.confirm(`국내 대학 "${univName}"을 삭제할까요?`)) return;
deleteMutation.mutate(id);
};

Expand All @@ -97,14 +97,14 @@ export function HomeUniversitiesPageContent() {
return (
<AdminLayout
activeMenu="homeUniversities"
title="협정 대학 관리"
description="자교 협정 대학과 최대 지망 수를 관리합니다."
title="국내 대학 관리"
description="자교 국내 대학과 최대 지망 수를 관리합니다."
>
<div className="mt-4">
<section className="rounded-xl border border-k-100 bg-k-0 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="typo-sb-9 text-k-900">협정 대학</h2>
<h2 className="typo-sb-9 text-k-900">국내 대학</h2>
<p className="mt-1 typo-regular-4 text-k-500">예: 인하대학교</p>
</div>
<p className="typo-regular-4 text-k-500">총 {universities.length.toLocaleString()}건</p>
Expand Down Expand Up @@ -144,13 +144,13 @@ export function HomeUniversitiesPageContent() {
) : query.isError ? (
<TableRow>
<TableCell colSpan={4} className="text-center typo-regular-4 text-magic-danger">
협정 대학을 불러오지 못했습니다.
국내 대학을 불러오지 못했습니다.
</TableCell>
</TableRow>
) : universities.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center typo-regular-4 text-k-500">
협정 대학이 없습니다.
국내 대학이 없습니다.
</TableCell>
</TableRow>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ import { UnivApplyInfoManageTab } from "./tabs/UnivApplyInfoManageTab";

export function UnivApplyInfosPageContent() {
return (
<AdminLayout
activeMenu="univApplyInfos"
title="지원 대학 관리"
description="호스트 대학교와 지원 대학을 관리합니다."
>
<AdminLayout activeMenu="univApplyInfos" title="지원 대학 관리" description="해외 대학과 지원 대학을 관리합니다.">
<div className="mt-4">
<Tabs defaultValue="hostUniversity">
<TabsList>
<TabsTrigger value="hostUniversity">호스트 대학교</TabsTrigger>
<TabsTrigger value="hostUniversity">해외 대학</TabsTrigger>
<TabsTrigger value="import">지원 대학 가져오기</TabsTrigger>
<TabsTrigger value="manage">지원 대학 관리</TabsTrigger>
</TabsList>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function renderTab() {

async function openCreateModal() {
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "호스트 대학교 생성" }));
fireEvent.click(await screen.findByRole("button", { name: "해외 대학 생성" }));
}

describe("HostUniversityTab image uploads", () => {
Expand Down Expand Up @@ -78,7 +78,6 @@ describe("HostUniversityTab image uploads", () => {

fireEvent.change(screen.getByLabelText("한글명 *"), { target: { value: "테스트 대학교" } });
fireEvent.change(screen.getByLabelText("영문명 *"), { target: { value: "Test University" } });
fireEvent.change(screen.getByLabelText("표시명 *"), { target: { value: "Test U" } });
fireEvent.change(screen.getByLabelText("국가코드 *"), { target: { value: "JP" } });
fireEvent.change(screen.getByLabelText("권역코드 *"), { target: { value: "ASIA" } });

Expand All @@ -94,7 +93,6 @@ describe("HostUniversityTab image uploads", () => {

fireEvent.change(screen.getByLabelText("한글명 *"), { target: { value: "테스트 대학교" } });
fireEvent.change(screen.getByLabelText("영문명 *"), { target: { value: "Test University" } });
fireEvent.change(screen.getByLabelText("표시명 *"), { target: { value: "Test U" } });
fireEvent.change(screen.getByLabelText("국가코드 *"), { target: { value: "JP" } });
fireEvent.change(screen.getByLabelText("권역코드 *"), { target: { value: "ASIA" } });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,25 @@ import { normalizeImageUrlToUploadCdn } from "@/lib/utils/cdnUrl";

type ModalState = { open: false } | { open: true; mode: "create" } | { open: true; mode: "edit"; id: number };

interface HostUniversityFormState extends HostUniversityPayload {
interface HostUniversityFormState extends Omit<HostUniversityPayload, "formatName"> {
logoImageUrl: string;
backgroundImageUrl: string;
}

const REQUIRED_FIELDS = ["koreanName", "englishName", "formatName", "countryCode", "regionCode"] as const;
function toFormatName(englishName: string): string {
return englishName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
Comment on lines +27 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

1. 정규화 결과가 빈 formatName이 될 수 있습니다.

englishName이 공백뿐이거나 정규화 과정에서 전부 제거되는 값이면 여기서 formatName""가 됩니다. 지금은 제출 전에 이 케이스를 막지 않아서 필수 API 필드에 빈 값이 실려 나갈 수 있습니다.

Also applies to: 77-81

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.tsx`
around lines 27 - 33, `toFormatName` can return an empty string when
`englishName` is blank or fully stripped by normalization, so add validation in
the `toFormatName` helper and the submit flow in `HostUniversityTab` to reject
or disable submission when `formatName` is empty. Ensure the logic that
builds/sends the API payload checks the normalized result before use and
prevents `""` from being sent for the required `formatName` field.


const REQUIRED_FIELDS = ["koreanName", "englishName", "countryCode", "regionCode"] as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update tests for the removed formatName input

When the admin Vitest tests are run, removing formatName from REQUIRED_FIELDS means the modal no longer renders the 표시명 * control, but HostUniversityTab.test.tsx still calls screen.getByLabelText("표시명 *") in both create-submit tests. Those tests now throw before reaching their assertions, so the change should update the tests to assert the generated formatName instead of filling the removed input.

Useful? React with 👍 / 👎.

const OPTIONAL_FIELDS = ["homepageUrl", "englishCourseUrl", "accommodationUrl"] as const;

const FIELD_LABELS: Record<string, string> = {
koreanName: "한글명",
englishName: "영문명",
formatName: "표시명",
countryCode: "국가코드",
regionCode: "권역코드",
homepageUrl: "홈페이지 URL",
Expand All @@ -42,7 +49,6 @@ const FIELD_LABELS: Record<string, string> = {
const EMPTY_FORM: HostUniversityFormState = {
koreanName: "",
englishName: "",
formatName: "",
logoImageUrl: "",
backgroundImageUrl: "",
countryCode: "",
Expand All @@ -57,7 +63,6 @@ function detailToForm(detail: HostUniversityDetailResponse): HostUniversityFormS
return {
koreanName: detail.koreanName,
englishName: detail.englishName,
formatName: detail.formatName,
logoImageUrl: detail.logoImageUrl,
backgroundImageUrl: detail.backgroundImageUrl,
countryCode: detail.countryCode,
Expand All @@ -73,7 +78,7 @@ function toPayload(form: HostUniversityFormState): HostUniversityPayload {
return {
koreanName: form.koreanName,
englishName: form.englishName,
formatName: form.formatName,
formatName: toFormatName(form.englishName),
countryCode: form.countryCode,
regionCode: form.regionCode,
homepageUrl: form.homepageUrl || undefined,
Expand Down Expand Up @@ -141,7 +146,7 @@ export function HostUniversityTab() {
onSuccess: async () => {
await invalidate();
closeModal();
toast.success("호스트 대학교를 생성했습니다.");
toast.success("해외 대학을 생성했습니다.");
},
onError: (e: unknown) => {
const msg = e instanceof Error ? e.message : "생성에 실패했습니다.";
Expand All @@ -164,7 +169,7 @@ export function HostUniversityTab() {
onSuccess: async () => {
await invalidate();
closeModal();
toast.success("호스트 대학교를 수정했습니다.");
toast.success("해외 대학을 수정했습니다.");
},
onError: (e: unknown) => {
const msg = e instanceof Error ? e.message : "수정에 실패했습니다.";
Expand All @@ -176,7 +181,7 @@ export function HostUniversityTab() {
mutationFn: (id: number) => adminApi.deleteHostUniversity(id),
onSuccess: async () => {
await invalidate();
toast.success("호스트 대학교를 삭제했습니다.");
toast.success("해외 대학을 삭제했습니다.");
},
onError: (e: unknown) => {
const msg = e instanceof Error ? e.message : "삭제에 실패했습니다.";
Expand Down Expand Up @@ -266,9 +271,9 @@ export function HostUniversityTab() {
<div className="space-y-4">
<section className="rounded-xl border border-k-100 bg-k-0 p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="typo-sb-9 text-k-900">호스트 대학교</h2>
<h2 className="typo-sb-9 text-k-900">해외 대학</h2>
<Button type="button" onClick={handleOpenCreate}>
호스트 대학교 생성
해외 대학 생성
</Button>
</div>
<form onSubmit={handleSearch} className="mt-3 flex flex-wrap gap-2">
Expand Down Expand Up @@ -432,9 +437,7 @@ export function HostUniversityTab() {
className="relative flex max-h-[90vh] w-full max-w-lg flex-col overflow-y-auto rounded-xl bg-k-0 shadow-xl"
>
<div className="flex items-center justify-between border-b border-k-100 px-5 py-4">
<p className="typo-sb-9 text-k-900">
{modal.mode === "create" ? "호스트 대학교 생성" : "호스트 대학교 수정"}
</p>
<p className="typo-sb-9 text-k-900">{modal.mode === "create" ? "해외 대학 생성" : "해외 대학 수정"}</p>
<button type="button" onClick={closeModal} className="typo-regular-4 text-k-400 hover:text-k-700">
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function UnivApplyInfoImportTab() {
const univId = Number(homeUniversityId);
const term = Number(termId);
if (!univId || !term) {
toast.error("협정 대학과 학기를 선택해주세요.");
toast.error("국내 대학과 학기를 선택해주세요.");
return;
}
if (!markdown.trim()) {
Expand Down Expand Up @@ -206,15 +206,15 @@ export function UnivApplyInfoImportTab() {
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="typo-sb-11 text-k-700" htmlFor={homeUniversitySelectId}>
협정 대학
국내 대학
</label>
<select
id={homeUniversitySelectId}
value={homeUniversityId}
onChange={(e) => setHomeUniversityId(e.target.value)}
className="h-9 w-full rounded-md border border-k-200 bg-k-0 px-3 typo-regular-4 text-k-700 outline-none focus-visible:border-primary"
>
<option value="">협정 대학 선택</option>
<option value="">국내 대학 선택</option>
{universities.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
Expand All @@ -223,7 +223,7 @@ export function UnivApplyInfoImportTab() {
</select>
{homeUniversitiesQuery.isLoading && <p className="mt-1 typo-regular-4 text-k-500">불러오는 중...</p>}
{homeUniversitiesQuery.isError && (
<p className="mt-1 typo-regular-4 text-magic-danger">협정 대학을 불러오지 못했습니다.</p>
<p className="mt-1 typo-regular-4 text-magic-danger">국내 대학을 불러오지 못했습니다.</p>
)}
</div>
<div className="space-y-1.5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function UnivApplyInfoManageTab() {
e.preventDefault();
const { termId, homeUniversityId, hostUniversityId } = createForm;
if (!termId || !homeUniversityId || !hostUniversityId) {
toast.error("학기, 협정 대학, 호스트 대학교는 필수입니다.");
toast.error("학기, 국내 대학, 해외 대학는 필수입니다.");
return;
}

Expand Down Expand Up @@ -278,7 +278,7 @@ export function UnivApplyInfoManageTab() {
<TableHead>ID</TableHead>
<TableHead>학기</TableHead>
<TableHead>대학명</TableHead>
<TableHead>협정대학</TableHead>
<TableHead>국내 대학</TableHead>
<TableHead>국가</TableHead>
<TableHead>정원</TableHead>
<TableHead>작업</TableHead>
Expand Down Expand Up @@ -502,7 +502,7 @@ export function UnivApplyInfoManageTab() {
</div>
<div className="space-y-1">
<label htmlFor={`${uid}-create-home-univ`} className="typo-sb-11 text-k-700">
협정 대학 *
국내 대학 *
</label>
<select
id={`${uid}-create-home-univ`}
Expand All @@ -516,7 +516,7 @@ export function UnivApplyInfoManageTab() {
required
className="h-9 w-full rounded-md border border-k-200 bg-k-0 px-3 typo-regular-4 text-k-700 outline-none focus-visible:border-primary"
>
<option value="">협정 대학 선택</option>
<option value="">국내 대학 선택</option>
{(homeUniversitiesQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
Expand All @@ -526,7 +526,7 @@ export function UnivApplyInfoManageTab() {
</div>
<div className="space-y-1">
<label htmlFor={`${uid}-create-host-keyword`} className="typo-sb-11 text-k-700">
호스트 대학교 *
해외 대학 *
</label>
<div className="flex gap-2">
<Input
Expand Down Expand Up @@ -560,7 +560,7 @@ export function UnivApplyInfoManageTab() {
className="mt-1 w-full rounded-md border border-k-200 bg-k-0 px-3 py-1 typo-regular-4 text-k-700"
>
<option value="" disabled>
호스트 대학교 선택
해외 대학 선택
</option>
{hostSearchQuery2.data?.content.map((u) => (
<option key={u.id} value={u.id}>
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/components/layout/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const sideMenus = [
{ key: "scores", label: "성적 관리", icon: FileText, to: "/scores" as const },
{ key: "mentorApplications", label: "멘토 승격 요청", icon: UserCheck, to: "/mentor-applications" as const },
{ key: "regionsCountries", label: "권역/지역 관리", icon: MapPinned, to: "/regions-countries" as const },
{ key: "homeUniversities", label: "협정 대학 관리", icon: Building2, to: "/home-universities" as const },
{ key: "homeUniversities", label: "국내 대학 관리", icon: Building2, to: "/home-universities" as const },
{ key: "terms", label: "학기 관리", icon: CalendarDays, to: "/terms" as const },
{ key: "univApplyInfos", label: "지원 대학 관리", icon: PlusCircle, to: "/univ-apply-infos" as const },
{ key: "bruno", label: "Bruno API", icon: FlaskConical, to: "/bruno" as const },
Expand Down
Loading