From a44e906c8a0fe3101a9ddfd2fa322d207e66d6c5 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 15:56:12 +0900 Subject: [PATCH 01/33] =?UTF-8?q?=E2=9C=A8=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20HomeUniversity=C2=B7UnivApplyInfo=20API=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=A8=EC=88=98=20=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 | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts index 6ebad569..e5fc40c6 100644 --- a/apps/admin/src/lib/api/admin.ts +++ b/apps/admin/src/lib/api/admin.ts @@ -62,6 +62,34 @@ export interface CountryPayload { regionCode: string; } +export interface HomeUniversityResponse { + id: number; + name: string; + maxChoiceCount: number; +} + +export interface HomeUniversityPayload { + name: string; + maxChoiceCount: number; +} + +export interface UnivApplyInfoFieldResponse { + structuredFields: { field: string; aliases: string[] }[]; + languageTestTypes: string[]; +} + +export interface UnivApplyInfoImportRequest { + termId: number; + homeUniversityId: number; + markdown: string; + columnMappings: Record; +} + +export interface UnivApplyInfoImportResponse { + successCount: number; + failedRows: { rowNumber: number; reason: string }[]; +} + const assignMentorApplicationUniversity = (mentorApplicationId: string | number, universityId: number) => axiosInstance .post(`/admin/mentor-applications/${mentorApplicationId}/assign-university`, { universityId }) @@ -128,4 +156,22 @@ export const adminApi = { axiosInstance.put(`/admin/countries/${code}`, data).then((res) => res.data), delete지역삭제: (code: string) => axiosInstance.delete(`/admin/countries/${code}`).then((res) => res.data), + + getHomeUniversities: () => + axiosInstance.get("/admin/home-universities").then((res) => res.data), + + createHomeUniversity: (data: HomeUniversityPayload) => + axiosInstance.post("/admin/home-universities", data).then((res) => res.data), + + updateHomeUniversity: (id: number, data: HomeUniversityPayload) => + axiosInstance.put(`/admin/home-universities/${id}`, data).then((res) => res.data), + + deleteHomeUniversity: (id: number) => + axiosInstance.delete(`/admin/home-universities/${id}`).then((res) => res.data), + + getUnivApplyInfoFields: () => + 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), }; From fbb75cd80bad7f1230249c1d3703ed3ee74c6fc2 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 15:59:29 +0900 Subject: [PATCH 02/33] =?UTF-8?q?=E2=9C=A8=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=EC=97=90=20=ED=98=91?= =?UTF-8?q?=EC=A0=95=20=EB=8C=80=ED=95=99=20=EA=B4=80=EB=A6=AC=C2=B7?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=8C=80=ED=95=99=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=82=BD=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components/layout/AdminSidebar.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/admin/src/components/layout/AdminSidebar.tsx b/apps/admin/src/components/layout/AdminSidebar.tsx index d4aadf8a..7003562e 100644 --- a/apps/admin/src/components/layout/AdminSidebar.tsx +++ b/apps/admin/src/components/layout/AdminSidebar.tsx @@ -1,12 +1,21 @@ -import { FileText, FlaskConical, MapPinned, MessageSquare, UserCheck } from "lucide-react"; +import { Building2, FileText, FlaskConical, MapPinned, MessageSquare, PlusCircle, UserCheck } from "lucide-react"; import { cn } from "@/lib/utils"; -export type ActiveAdminMenu = "scores" | "mentorApplications" | "regionsCountries" | "bruno" | "chatSocket"; +export type ActiveAdminMenu = + | "scores" + | "mentorApplications" + | "regionsCountries" + | "homeUniversities" + | "univApplyInfos" + | "bruno" + | "chatSocket"; 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: "univApplyInfos", label: "지원 대학 추가", icon: PlusCircle, to: "/univ-apply-infos" as const }, { key: "bruno", label: "Bruno API", icon: FlaskConical, to: "/bruno" as const }, { key: "chatSocket", label: "채팅 소켓", icon: MessageSquare, to: "/chat-socket" as const }, ] as const; From 05eb6143b0d52aaa278188cc7c4db7ba63dcb1d7 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 16:03:24 +0900 Subject: [PATCH 03/33] =?UTF-8?q?=E2=9C=A8=20=ED=98=91=EC=A0=95=20?= =?UTF-8?q?=EB=8C=80=ED=95=99=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/app/home-universities/page.tsx | 10 + .../HomeUniversitiesPageContent.tsx | 224 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 apps/admin/src/app/home-universities/page.tsx create mode 100644 apps/admin/src/components/features/home-universities/HomeUniversitiesPageContent.tsx diff --git a/apps/admin/src/app/home-universities/page.tsx b/apps/admin/src/app/home-universities/page.tsx new file mode 100644 index 00000000..a364cda9 --- /dev/null +++ b/apps/admin/src/app/home-universities/page.tsx @@ -0,0 +1,10 @@ +import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession"; +import { HomeUniversitiesPageContent } from "@/components/features/home-universities/HomeUniversitiesPageContent"; + +export default function HomeUniversitiesPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/features/home-universities/HomeUniversitiesPageContent.tsx b/apps/admin/src/components/features/home-universities/HomeUniversitiesPageContent.tsx new file mode 100644 index 00000000..68169d9c --- /dev/null +++ b/apps/admin/src/components/features/home-universities/HomeUniversitiesPageContent.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type FormEvent, useState } from "react"; +import { toast } from "sonner"; +import { AdminLayout } from "@/components/layout/AdminLayout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { adminApi, type HomeUniversityResponse } from "@/lib/api/admin"; + +export function HomeUniversitiesPageContent() { + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [maxChoiceCount, setMaxChoiceCount] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(""); + const [editingMaxChoiceCount, setEditingMaxChoiceCount] = useState(""); + + const query = useQuery({ + queryKey: ["admin", "home-universities"], + queryFn: adminApi.getHomeUniversities, + placeholderData: keepPreviousData, + }); + + const universities = query.data ?? []; + + const invalidate = async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "home-universities"] }); + }; + + const createMutation = useMutation({ + mutationFn: adminApi.createHomeUniversity, + onSuccess: async () => { + await invalidate(); + setName(""); + setMaxChoiceCount(""); + toast.success("협정 대학을 생성했습니다."); + }, + onError: () => toast.error("협정 대학 생성에 실패했습니다."), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, ...data }: { id: number; name: string; maxChoiceCount: number }) => + adminApi.updateHomeUniversity(id, data), + onSuccess: async () => { + await invalidate(); + setEditingId(null); + toast.success("협정 대학을 수정했습니다."); + }, + onError: () => toast.error("협정 대학 수정에 실패했습니다."), + }); + + const deleteMutation = useMutation({ + mutationFn: adminApi.deleteHomeUniversity, + onSuccess: async () => { + await invalidate(); + toast.success("협정 대학을 삭제했습니다."); + }, + onError: () => toast.error("협정 대학 삭제에 실패했습니다."), + }); + + const handleCreate = (e: FormEvent) => { + e.preventDefault(); + const trimmedName = name.trim(); + const count = Number(maxChoiceCount); + if (!trimmedName || !Number.isInteger(count) || count < 1) { + toast.error("대학명과 최대 지망 수(1 이상)를 입력해주세요."); + return; + } + createMutation.mutate({ name: trimmedName, maxChoiceCount: count }); + }; + + const handleStartEdit = (univ: HomeUniversityResponse) => { + setEditingId(univ.id); + setEditingName(univ.name); + setEditingMaxChoiceCount(String(univ.maxChoiceCount)); + }; + + const handleUpdate = (id: number) => { + const trimmedName = editingName.trim(); + const count = Number(editingMaxChoiceCount); + if (!trimmedName || !Number.isInteger(count) || count < 1) { + toast.error("대학명과 최대 지망 수(1 이상)를 입력해주세요."); + return; + } + updateMutation.mutate({ id, name: trimmedName, maxChoiceCount: count }); + }; + + const handleDelete = (id: number, univName: string) => { + if (!window.confirm(`협정 대학 "${univName}"을 삭제할까요?`)) return; + deleteMutation.mutate(id); + }; + + const isMutating = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; + + return ( + +
+
+
+
+

협정 대학

+

예: 인하대학교

+
+

총 {universities.length.toLocaleString()}건

+
+ +
+ setName(e.target.value)} placeholder="대학명" /> + setMaxChoiceCount(e.target.value)} + placeholder="최대 지망 수" + type="number" + min={1} + /> + +
+ +
+ + + + ID + 대학명 + 최대 지망 수 + 작업 + + + + {query.isLoading ? ( + + + 불러오는 중... + + + ) : query.isError ? ( + + + 협정 대학을 불러오지 못했습니다. + + + ) : universities.length === 0 ? ( + + + 협정 대학이 없습니다. + + + ) : ( + universities.map((univ) => { + const isEditing = editingId === univ.id; + return ( + + {univ.id} + + {isEditing ? ( + setEditingName(e.target.value)} /> + ) : ( + univ.name + )} + + + {isEditing ? ( + setEditingMaxChoiceCount(e.target.value)} + type="number" + min={1} + className="w-24" + /> + ) : ( + univ.maxChoiceCount + )} + + + {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); + }) + )} +
+
+
+
+
+
+ ); +} From ef219589a38929b365af38798f041d4dc48ca2f3 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 16:11:43 +0900 Subject: [PATCH 04/33] =?UTF-8?q?=E2=9C=A8=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=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/app/univ-apply-infos/page.tsx | 10 + .../UnivApplyInfosPageContent.tsx | 286 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 apps/admin/src/app/univ-apply-infos/page.tsx create mode 100644 apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx diff --git a/apps/admin/src/app/univ-apply-infos/page.tsx b/apps/admin/src/app/univ-apply-infos/page.tsx new file mode 100644 index 00000000..1e975026 --- /dev/null +++ b/apps/admin/src/app/univ-apply-infos/page.tsx @@ -0,0 +1,10 @@ +import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession"; +import { UnivApplyInfosPageContent } from "@/components/features/univ-apply-infos/UnivApplyInfosPageContent"; + +export default function UnivApplyInfosPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx new file mode 100644 index 00000000..76b77d41 --- /dev/null +++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { type FormEvent, useId, useState } from "react"; +import { toast } from "sonner"; +import { AdminLayout } from "@/components/layout/AdminLayout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { adminApi, type UnivApplyInfoFieldResponse, type UnivApplyInfoImportResponse } from "@/lib/api/admin"; + +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 lines[0] + .split("|") + .map((h) => h.trim()) + .filter((h) => h.length > 0); +} + +function buildAutoMappings(headers: string[], fields: UnivApplyInfoFieldResponse): Record { + const mappings: Record = {}; + for (const header of headers) { + const matched = fields.structuredFields.find((f) => f.field === header || f.aliases.some((a) => a === header)); + if (matched) { + mappings[header] = matched.field; + continue; + } + if (fields.languageTestTypes.includes(header)) { + mappings[header] = header; + } + } + return mappings; +} + +export function UnivApplyInfosPageContent() { + const homeUniversitySelectId = useId(); + const termInputId = 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 homeUniversitiesQuery = useQuery({ + queryKey: ["admin", "home-universities"], + queryFn: adminApi.getHomeUniversities, + }); + + const fieldsQuery = useQuery({ + queryKey: ["admin", "univ-apply-info-fields"], + queryFn: adminApi.getUnivApplyInfoFields, + staleTime: Number.POSITIVE_INFINITY, + }); + + const importMutation = useMutation({ + mutationFn: adminApi.importUnivApplyInfos, + onSuccess: (data) => { + setImportResult(data); + if (data.failedRows.length === 0) { + toast.success(`${data.successCount}건 모두 추가됐습니다.`); + } else { + toast.warning(`성공 ${data.successCount}건, 실패 ${data.failedRows.length}건`); + } + }, + 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 = fieldsQuery.data ? buildAutoMappings(headers, fieldsQuery.data) : {}; + setParsedHeaders(headers); + setColumnMappings(auto); + setImportResult(null); + }; + + const handleImport = (e: FormEvent) => { + e.preventDefault(); + const univId = Number(homeUniversityId); + const term = Number(termId); + if (!univId || !Number.isInteger(term) || term < 1) { + toast.error("협정 대학과 학기 ID를 입력해주세요."); + return; + } + if (!markdown.trim()) { + toast.error("마크다운을 입력해주세요."); + return; + } + if (parsedHeaders.length === 0) { + toast.error("먼저 [파싱] 버튼을 눌러 컬럼을 확인해주세요."); + return; + } + importMutation.mutate({ + homeUniversityId: univId, + termId: term, + markdown: markdown.trim(), + columnMappings, + }); + }; + + const universities = homeUniversitiesQuery.data ?? []; + const fields = fieldsQuery.data; + const fieldOptions = fields + ? [ + ...fields.structuredFields.map((f) => ({ value: f.field, label: f.field })), + ...fields.languageTestTypes.map((t) => ({ value: t, label: t })), + { value: "extraInfo", label: "extraInfo (기타)" }, + ] + : []; + + return ( + +
+ {/* ① 기본 정보 */} +
+

① 기본 정보

+
+
+ + + {homeUniversitiesQuery.isLoading &&

불러오는 중...

} + {homeUniversitiesQuery.isError && ( +

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

+ )} +
+
+ + setTermId(e.target.value)} + placeholder="예: 1" + type="number" + min={1} + /> +
+
+
+ + {/* ② 마크다운 입력 */} +
+

② 마크다운 입력

+

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

+