From 788b4cee4277c84a6fd7573e98d0927d805a8490 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 21 Jun 2026 15:16:27 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9C=A8=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20API=20=EB=A0=88=EC=9D=B4=EC=96=B4=EC=97=90=20=ED=98=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=8C=80=ED=95=99=EA=B5=90=C2=B7=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EB=8C=80=ED=95=99=20CRUD=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/lib/api/admin.ts | 155 +++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts index 882d928a..36fca801 100644 --- a/apps/admin/src/lib/api/admin.ts +++ b/apps/admin/src/lib/api/admin.ts @@ -73,6 +73,126 @@ export interface HomeUniversityPayload { maxChoiceCount: number; } +export interface PageResponse { + content: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface HostUniversityListParams { + keyword?: string; + countryCode?: string; + regionCode?: string; + page?: number; + size?: number; +} + +export interface HostUniversityResponse { + id: number; + koreanName: string; + englishName: string; + formatName: string; + logoImageUrl: string; + countryCode: string; + countryKoreanName: string; + regionCode: string; + regionKoreanName: string; +} + +export interface HostUniversityDetailResponse { + id: number; + koreanName: string; + englishName: string; + formatName: string; + logoImageUrl: string; + backgroundImageUrl: string; + countryCode: string; + countryKoreanName: string; + regionCode: string; + regionKoreanName: string; + homepageUrl?: string; + englishCourseUrl?: string; + accommodationUrl?: string; + detailsForLocal?: string; +} + +export interface HostUniversityPayload { + koreanName: string; + englishName: string; + formatName: string; + logoImageUrl: string; + backgroundImageUrl: string; + countryCode: string; + regionCode: string; + homepageUrl?: string; + englishCourseUrl?: string; + accommodationUrl?: string; + detailsForLocal?: string; +} + +export interface UnivApplyInfoLanguageRequirement { + languageTestType: string; + minScore: string; +} + +export interface UnivApplyInfoManageResponse { + id: number; + termId: number; + homeUniversityId: number | null; + hostUniversityId: number; + koreanName: string; + studentCapacity?: number; + semesterAvailableForDispatch?: string; + semesterRequirement?: string; + detailsForLanguage?: string; + gpaRequirement?: string; + gpaRequirementCriteria?: string; + detailsForAccommodation?: string; + extraInfo?: Record; + languageRequirements: UnivApplyInfoLanguageRequirement[]; +} + +export interface UnivApplyInfoCreatePayload { + termId: number; + homeUniversityId: number; + hostUniversityId: number; + studentCapacity?: number; + semesterAvailableForDispatch?: string; + semesterRequirement?: string; + detailsForLanguage?: string; + gpaRequirement?: string; + gpaRequirementCriteria?: string; + detailsForAccommodation?: string; + extraInfo?: Record; + languageRequirements?: UnivApplyInfoLanguageRequirement[]; +} + +export interface UnivApplyInfoUpdatePayload { + studentCapacity?: number; + semesterAvailableForDispatch?: string; + semesterRequirement?: string; + detailsForLanguage?: string; + gpaRequirement?: string; + gpaRequirementCriteria?: string; + detailsForAccommodation?: string; + extraInfo?: Record; + languageRequirements?: UnivApplyInfoLanguageRequirement[]; +} + +export interface UnivApplyInfoSearchResult { + id: number; + term: string; + koreanName: string; + homeUniversityName?: string; + region: string; + country: string; + logoImageUrl: string; + studentCapacity?: number; + languageRequirements: UnivApplyInfoLanguageRequirement[]; +} + export interface TermResponse { id: number; label: string; @@ -205,5 +325,38 @@ export const adminApi = { axiosInstance.get("/admin/univ-apply-infos/fields").then((res) => res.data), importUnivApplyInfos: (data: UnivApplyInfoImportRequest) => - axiosInstance.post("/admin/univ-apply-infos", data).then((res) => res.data), + axiosInstance.post("/admin/univ-apply-infos/import", data).then((res) => res.data), + + getHostUniversities: (params?: HostUniversityListParams) => + axiosInstance + .get>("/admin/host-universities", { params }) + .then((res) => res.data), + + getHostUniversity: (id: number) => + axiosInstance.get(`/admin/host-universities/${id}`).then((res) => res.data), + + createHostUniversity: (data: HostUniversityPayload) => + axiosInstance.post("/admin/host-universities", data).then((res) => res.data), + + updateHostUniversity: (id: number, data: HostUniversityPayload) => + axiosInstance.put(`/admin/host-universities/${id}`, data).then((res) => res.data), + + deleteHostUniversity: (id: number) => + axiosInstance.delete(`/admin/host-universities/${id}`).then((res) => res.data), + + createUnivApplyInfo: (data: UnivApplyInfoCreatePayload) => + axiosInstance.post("/admin/univ-apply-infos", data).then((res) => res.data), + + updateUnivApplyInfo: (id: number, data: UnivApplyInfoUpdatePayload) => + axiosInstance.patch(`/admin/univ-apply-infos/${id}`, data).then((res) => res.data), + + deleteUnivApplyInfo: (id: number) => + axiosInstance.delete(`/admin/univ-apply-infos/${id}`).then((res) => res.data), + + searchUnivApplyInfos: (value: string) => + axiosInstance + .get<{ univApplyInfoPreviews: UnivApplyInfoSearchResult[] }>("/univ-apply-infos/search/text", { + params: { value }, + }) + .then((res) => res.data), }; From 30b7f7e0645aed4186c374d0762b7b6290d0f41c Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 21 Jun 2026 15:21:59 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=99=20=EC=B6=94=EA=B0=80=20=E2=86=92=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EB=8C=80=ED=95=99=20=EA=B4=80=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8=20(=ED=83=AD=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EA=B5=AC=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UnivApplyInfosPageContent.tsx | 488 +----------------- .../tabs/HostUniversityTab.tsx | 3 + .../tabs/UnivApplyInfoImportTab.tsx | 3 + .../tabs/UnivApplyInfoManageTab.tsx | 3 + .../src/components/layout/AdminSidebar.tsx | 2 +- 5 files changed, 34 insertions(+), 465 deletions(-) create mode 100644 apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.tsx create mode 100644 apps/admin/src/components/features/univ-apply-infos/tabs/UnivApplyInfoImportTab.tsx create mode 100644 apps/admin/src/components/features/univ-apply-infos/tabs/UnivApplyInfoManageTab.tsx 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 61df62a9..bc1c3322 100644 --- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx +++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx @@ -1,476 +1,36 @@ "use client"; -import { useMutation, useQuery } from "@tanstack/react-query"; -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 { - 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"; -import { buildPreviewRows, getPreviewCellError, parseMarkdownRow } from "./univApplyInfoPreview"; -import { validatePreviewRows } from "./univApplyInfoValidation"; - -function extractMarkdownHeaders(markdown: string): string[] { - const lines = markdown.trim().split("\n"); - if (lines.length < 2) return []; - const separatorPattern = /^\|[-| :]+\|$/; - if (!separatorPattern.test(lines[1].trim())) return []; - return parseMarkdownRow(lines[0]).filter((h) => h.length > 0); -} - -function buildAutoMappings(headers: string[], languageTestTypes: string[]): Record { - const mappings: Record = {}; - for (const header of headers) { - const field = findFieldByHeader(header); - if (field) { - mappings[header] = field; - continue; - } - if (languageTestTypes.includes(header)) { - mappings[header] = header; - } - } - 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); +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { HostUniversityTab } from "./tabs/HostUniversityTab"; +import { UnivApplyInfoImportTab } from "./tabs/UnivApplyInfoImportTab"; +import { UnivApplyInfoManageTab } from "./tabs/UnivApplyInfoManageTab"; export function UnivApplyInfosPageContent() { - const homeUniversitySelectId = useId(); - const termSelectId = useId(); - - const [homeUniversityId, setHomeUniversityId] = useState(""); - const [termId, setTermId] = useState(""); - const [markdown, setMarkdown] = useState(""); - const [parsedHeaders, setParsedHeaders] = useState([]); - const [columnMappings, setColumnMappings] = useState>({}); - const [importResult, setImportResult] = useState(null); - const [showPreviewModal, setShowPreviewModal] = useState(false); - - const homeUniversitiesQuery = useQuery({ - queryKey: ["admin", "home-universities"], - queryFn: adminApi.getHomeUniversities, - }); - - const termsQuery = useQuery({ - queryKey: ["admin", "terms"], - queryFn: adminApi.getTerms, - }); - - const countriesQuery = useQuery({ - queryKey: ["admin", "countries"], - queryFn: adminApi.getCountries, - }); - - const fieldsQuery = useQuery({ - queryKey: ["admin", "univ-apply-info-fields"], - queryFn: adminApi.getUnivApplyInfoFields, - staleTime: Number.POSITIVE_INFINITY, - }); - - const importMutation = useMutation({ - mutationFn: adminApi.importUnivApplyInfos, - onSuccess: (data) => { - setShowPreviewModal(false); - setImportResult(data); - toast.success(`${data.successCount}건 모두 추가됐습니다.`); - }, - onError: () => toast.error("지원 대학 추가에 실패했습니다."), - }); - - const handleMarkdownChange = (e: React.ChangeEvent) => { - setMarkdown(e.target.value); - if (parsedHeaders.length > 0) { - setParsedHeaders([]); - setColumnMappings({}); - setImportResult(null); - } - }; - - const handleParse = () => { - const headers = extractMarkdownHeaders(markdown); - if (headers.length === 0) { - toast.error("마크다운 헤더를 파싱할 수 없습니다. 형식을 확인해주세요."); - return; - } - const auto = buildAutoMappings(headers, fieldsQuery.data?.languageTestTypes ?? []); - setParsedHeaders(headers); - setColumnMappings(auto); - setImportResult(null); - }; - - const handleImport = (e: FormEvent) => { - e.preventDefault(); - const univId = Number(homeUniversityId); - const term = Number(termId); - if (!univId || !term) { - toast.error("협정 대학과 학기를 선택해주세요."); - return; - } - if (!markdown.trim()) { - toast.error("마크다운을 입력해주세요."); - return; - } - if (parsedHeaders.length === 0) { - toast.error("먼저 [파싱] 버튼을 눌러 컬럼을 확인해주세요."); - return; - } - setShowPreviewModal(true); - }; - - const handleConfirmImport = () => { - if (!canConfirmImport) { - if (previewRows.length === 0) { - toast.error("추가할 지원 대학이 없습니다."); - } - return; - } - - const processedMarkdown = preprocessMarkdownCountryCodes(markdown.trim(), columnMappings); - importMutation.mutate({ - homeUniversityId: Number(homeUniversityId), - termId: Number(termId), - markdown: processedMarkdown, - columnMappings, - }); - }; - - 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 }[] = [ - // 필수 필드: 매핑 여부와 관계없이 항상 표시 - ...UNIV_APPLY_INFO_FIELDS.filter((f) => f.required).map((f) => ({ - field: f.field, - label: f.label, - required: true, - mapped: mappedFieldSet.has(f.field), - })), - // 비필수 시스템 필드: 매핑된 경우에만 표시 - ...UNIV_APPLY_INFO_FIELDS.filter((f) => !f.required && mappedFieldSet.has(f.field)).map((f) => ({ - field: f.field, - label: f.label, - required: false, - mapped: true, - })), - // 언어 시험 타입 컬럼 - ...[...mappedFieldSet] - .filter((f) => !UNIV_APPLY_INFO_FIELDS.some((sf) => sf.field === f)) - .map((f) => ({ field: f, label: f, required: false, mapped: true })), - ]; - const previewRows = showPreviewModal ? buildPreviewRows(markdown.trim(), columnMappings) : []; - 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; - const canConfirmImport = canConfirmUnivApplyInfoImport({ - previewRowCount: previewRows.length, - clientErrorCount: clientCellErrors.size, - isPending: importMutation.isPending, - }); - return ( -
- {/* ① 기본 정보 */} -
-

① 기본 정보

-
-
- - - {homeUniversitiesQuery.isLoading &&

불러오는 중...

} - {homeUniversitiesQuery.isError && ( -

협정 대학을 불러오지 못했습니다.

- )} -
-
- - - {termsQuery.isLoading &&

불러오는 중...

} - {termsQuery.isError && ( -

학기 목록을 불러오지 못했습니다.

- )} -
-
-
- - {/* ② 마크다운 입력 */} -
-

② 마크다운 입력

-

파이프(|)로 구분된 마크다운 테이블을 붙여넣으세요.

-