diff --git a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx index 0bacef31..61df62a9 100644 --- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx +++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx @@ -1,13 +1,18 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { type FormEvent, useId, useState } from "react"; +import { type FormEvent, useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { AdminLayout } from "@/components/layout/AdminLayout"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { adminApi, type UnivApplyInfoImportResponse } from "@/lib/api/admin"; +import { + type AdminCollection, + adminApi, + type CountryResponse, + type UnivApplyInfoImportResponse, +} from "@/lib/api/admin"; import { preprocessMarkdownCountryCodes } from "./countryCodeAliases"; import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields"; import { canConfirmUnivApplyInfoImport } from "./univApplyInfoImportGuard"; @@ -37,6 +42,20 @@ function buildAutoMappings(headers: string[], languageTestTypes: string[]): Reco return mappings; } +const toOptionalString = (value: string | number | null | undefined) => { + if (value === null || value === undefined) return undefined; + const normalized = String(value).trim(); + return normalized.length > 0 ? normalized : undefined; +}; + +const normalizeCollection = (data: AdminCollection | undefined) => { + if (!data) return []; + if (Array.isArray(data)) return data; + return data.content ?? data.data ?? data.items ?? data.result ?? []; +}; + +const getCountryCode = (country: CountryResponse) => toOptionalString(country.code); + export function UnivApplyInfosPageContent() { const homeUniversitySelectId = useId(); const termSelectId = useId(); @@ -59,6 +78,11 @@ export function UnivApplyInfosPageContent() { queryFn: adminApi.getTerms, }); + const countriesQuery = useQuery({ + queryKey: ["admin", "countries"], + queryFn: adminApi.getCountries, + }); + const fieldsQuery = useQuery({ queryKey: ["admin", "univ-apply-info-fields"], queryFn: adminApi.getUnivApplyInfoFields, @@ -135,6 +159,15 @@ export function UnivApplyInfosPageContent() { const universities = homeUniversitiesQuery.data ?? []; const terms = termsQuery.data ?? []; const fields = fieldsQuery.data; + const validCountryCodes = useMemo( + () => + new Set( + normalizeCollection(countriesQuery.data) + .map(getCountryCode) + .filter((code): code is string => Boolean(code)), + ), + [countriesQuery.data], + ); const mappedFieldSet = new Set(Object.values(columnMappings).filter(Boolean)); const previewColumns: { field: string; label: string; required: boolean; mapped: boolean }[] = [ @@ -158,7 +191,9 @@ export function UnivApplyInfosPageContent() { .map((f) => ({ field: f, label: f, required: false, mapped: true })), ]; const previewRows = showPreviewModal ? buildPreviewRows(markdown.trim(), columnMappings) : []; - const clientCellErrors = validatePreviewRows(previewRows); + const clientCellErrors = validatePreviewRows(previewRows, { + validCountryCodes: countriesQuery.isSuccess ? validCountryCodes : undefined, + }); // key format: "rowNumber:field:fieldName" — rowNumber is always the first segment const clientErrorRowNumbers = new Set([...clientCellErrors.keys()].map((k) => Number(k.split(":")[0]))); const failedCellMessages = clientCellErrors; diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts index 332a3fb2..9dc3a7ff 100644 --- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts +++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts @@ -104,4 +104,25 @@ describe("validatePreviewRows", () => { expect(errors.get("1:field:universityCountryCode")).toContain("유효하지 않은 국가 코드"); expect(errors.get("1:field:studentCapacity")).toContain("정수"); }); + + it("rejects country codes that are absent from the server country list", () => { + const rows = [ + makeRow({ + universityKoreanName: { + header: "대학명 (국문)", + field: "universityKoreanName", + value: "괌 대학", + }, + universityCountryCode: { + header: "국가", + field: "universityCountryCode", + value: "ZZ", + }, + }), + ]; + + const errors = validatePreviewRows(rows, { validCountryCodes: new Set(["US", "JP"]) }); + + expect(errors.get("1:field:universityCountryCode")).toContain("서버에 등록되지 않은 국가 코드"); + }); }); diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts index a75ee8fa..671920f5 100644 --- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts +++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts @@ -21,6 +21,10 @@ type FieldRule = | { type: "enum"; values: readonly string[]; label: string } | { type: "format"; check: (v: string) => boolean; message: string }; +interface ValidatePreviewRowsOptions { + validCountryCodes?: ReadonlySet; +} + const FIELD_RULES: Record = { universityKoreanName: [ { type: "required", message: "대학명은 필수입니다" }, @@ -97,7 +101,7 @@ function validateCell(value: string, rules: FieldRule[]): string | undefined { return undefined; } -export function validatePreviewRows(rows: PreviewRow[]): Map { +export function validatePreviewRows(rows: PreviewRow[], options: ValidatePreviewRowsOptions = {}): Map { const errors = new Map(); for (const row of rows) { @@ -115,6 +119,15 @@ export function validatePreviewRows(rows: PreviewRow[]): Map { const message = validateCell(cell.value, rules); if (message) { errors.set(`${row.rowNumber}:field:${field}`, message); + continue; + } + if ( + field === "universityCountryCode" && + cell.value.trim() && + options.validCountryCodes && + !options.validCountryCodes.has(cell.value.trim()) + ) { + errors.set(`${row.rowNumber}:field:${field}`, "서버에 등록되지 않은 국가 코드입니다"); } } }