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()}건
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ {/* ⑤ 결과 */}
+ {importResult && (
+
+ 결과
+
+ 성공 {importResult.successCount}건
+ {importResult.failedRows.length > 0 && (
+ <>
+ {" "}
+ / 실패 {importResult.failedRows.length}건
+ >
+ )}
+
+ {importResult.failedRows.length > 0 && (
+
+
+
+
+ 행 번호
+ 실패 이유
+
+
+
+ {importResult.failedRows.map((row) => (
+
+ {row.rowNumber}
+ {row.reason}
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ );
+}
From 71826b17783e717e16d122d15f506ca8709a1a7d Mon Sep 17 00:00:00 2001
From: whqtker
Date: Mon, 15 Jun 2026 16:30:22 +0900
Subject: [PATCH 05/33] =?UTF-8?q?=F0=9F=94=A7=20superpowers=20=EC=84=A4?=
=?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C=20gitignore=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.gitignore b/.gitignore
index dc11a8f5..02e8e113 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,9 @@
packages/api-schema/src/apis/
packages/api-schema/.cache/
+# superpowers design docs
+docs/superpowers/
+
# misc
.DS_Store
*.pem
From d9173625814ceea2b26780a88a43b47637cca573 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Mon, 15 Jun 2026 17:09:39 +0900
Subject: [PATCH 06/33] =?UTF-8?q?=E2=9C=A8=20=ED=95=99=EA=B8=B0=20?=
=?UTF-8?q?=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?=
=?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20API=20=ED=86=B5=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 38 +++++++++++++------
apps/admin/src/lib/api/admin.ts | 7 ++++
2 files changed, 33 insertions(+), 12 deletions(-)
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 76b77d41..fa16a588 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -5,7 +5,6 @@ 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";
@@ -38,7 +37,7 @@ function buildAutoMappings(headers: string[], fields: UnivApplyInfoFieldResponse
export function UnivApplyInfosPageContent() {
const homeUniversitySelectId = useId();
- const termInputId = useId();
+ const termSelectId = useId();
const [homeUniversityId, setHomeUniversityId] = useState("");
const [termId, setTermId] = useState("");
@@ -52,6 +51,11 @@ export function UnivApplyInfosPageContent() {
queryFn: adminApi.getHomeUniversities,
});
+ const termsQuery = useQuery({
+ queryKey: ["admin", "terms"],
+ queryFn: adminApi.getTerms,
+ });
+
const fieldsQuery = useQuery({
queryKey: ["admin", "univ-apply-info-fields"],
queryFn: adminApi.getUnivApplyInfoFields,
@@ -96,8 +100,8 @@ export function UnivApplyInfosPageContent() {
e.preventDefault();
const univId = Number(homeUniversityId);
const term = Number(termId);
- if (!univId || !Number.isInteger(term) || term < 1) {
- toast.error("협정 대학과 학기 ID를 입력해주세요.");
+ if (!univId || !term) {
+ toast.error("협정 대학과 학기를 선택해주세요.");
return;
}
if (!markdown.trim()) {
@@ -117,6 +121,7 @@ export function UnivApplyInfosPageContent() {
};
const universities = homeUniversitiesQuery.data ?? [];
+ const terms = termsQuery.data ?? [];
const fields = fieldsQuery.data;
const fieldOptions = fields
? [
@@ -160,17 +165,26 @@ export function UnivApplyInfosPageContent() {
)}
-
diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts
index e5fc40c6..4d579fae 100644
--- a/apps/admin/src/lib/api/admin.ts
+++ b/apps/admin/src/lib/api/admin.ts
@@ -73,6 +73,11 @@ export interface HomeUniversityPayload {
maxChoiceCount: number;
}
+export interface TermResponse {
+ id: number;
+ label: string;
+}
+
export interface UnivApplyInfoFieldResponse {
structuredFields: { field: string; aliases: string[] }[];
languageTestTypes: string[];
@@ -169,6 +174,8 @@ export const adminApi = {
deleteHomeUniversity: (id: number) =>
axiosInstance.delete(`/admin/home-universities/${id}`).then((res) => res.data),
+ getTerms: () => axiosInstance.get("/admin/terms").then((res) => res.data),
+
getUnivApplyInfoFields: () =>
axiosInstance.get("/admin/univ-apply-infos/fields").then((res) => res.data),
From 1cd8588c76f2005f50e3eca83cedf3346306201c Mon Sep 17 00:00:00 2001
From: whqtker
Date: Mon, 15 Jun 2026 22:05:09 +0900
Subject: [PATCH 07/33] =?UTF-8?q?=E2=9C=A8=20=ED=95=99=EA=B8=B0=20?=
=?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?=
=?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20API=20=ED=86=B5=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/admin/src/app/terms/page.tsx | 10 ++
.../features/terms/TermsPageContent.tsx | 157 ++++++++++++++++++
.../features/terms/termValidation.test.ts | 12 ++
.../features/terms/termValidation.ts | 6 +
.../src/components/layout/AdminSidebar.tsx | 13 +-
apps/admin/src/lib/api/admin.ts | 10 ++
6 files changed, 207 insertions(+), 1 deletion(-)
create mode 100644 apps/admin/src/app/terms/page.tsx
create mode 100644 apps/admin/src/components/features/terms/TermsPageContent.tsx
create mode 100644 apps/admin/src/components/features/terms/termValidation.test.ts
create mode 100644 apps/admin/src/components/features/terms/termValidation.ts
diff --git a/apps/admin/src/app/terms/page.tsx b/apps/admin/src/app/terms/page.tsx
new file mode 100644
index 00000000..f44ac9cf
--- /dev/null
+++ b/apps/admin/src/app/terms/page.tsx
@@ -0,0 +1,10 @@
+import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession";
+import { TermsPageContent } from "@/components/features/terms/TermsPageContent";
+
+export default function TermsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/features/terms/TermsPageContent.tsx b/apps/admin/src/components/features/terms/TermsPageContent.tsx
new file mode 100644
index 00000000..50ede6ad
--- /dev/null
+++ b/apps/admin/src/components/features/terms/TermsPageContent.tsx
@@ -0,0 +1,157 @@
+"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 TermResponse } from "@/lib/api/admin";
+import { normalizeTermName } from "./termValidation";
+
+const TERMS_QUERY_KEY = ["admin", "terms"] as const;
+
+export function TermsPageContent() {
+ const queryClient = useQueryClient();
+ const [name, setName] = useState("");
+
+ const termsQuery = useQuery({
+ queryKey: TERMS_QUERY_KEY,
+ queryFn: adminApi.getTerms,
+ placeholderData: keepPreviousData,
+ });
+
+ const invalidateTerms = async () => {
+ await queryClient.invalidateQueries({ queryKey: TERMS_QUERY_KEY });
+ };
+
+ const createMutation = useMutation({
+ mutationFn: adminApi.createTerm,
+ onSuccess: async () => {
+ await invalidateTerms();
+ setName("");
+ toast.success("학기를 생성했습니다.");
+ },
+ onError: () => toast.error("학기 생성에 실패했습니다."),
+ });
+
+ const activateMutation = useMutation({
+ mutationFn: adminApi.activateTerm,
+ onSuccess: async () => {
+ await invalidateTerms();
+ toast.success("현재 학기를 변경했습니다.");
+ },
+ onError: () => toast.error("현재 학기 설정에 실패했습니다."),
+ });
+
+ const handleCreate = (event: FormEvent) => {
+ event.preventDefault();
+ const normalizedName = normalizeTermName(name);
+
+ if (!normalizedName) {
+ toast.error("학기 이름은 2026-1 형식으로 입력해주세요.");
+ return;
+ }
+
+ createMutation.mutate({ name: normalizedName });
+ };
+
+ const handleActivate = (term: TermResponse) => {
+ const confirmed = window.confirm(`"${term.label}" 학기를 현재 학기로 설정할까요?`);
+ if (!confirmed) return;
+
+ activateMutation.mutate(term.id);
+ };
+
+ const terms = termsQuery.data ?? [];
+ const isMutating = createMutation.isPending || activateMutation.isPending;
+
+ return (
+
+
+
+
+
+
총 {terms.length.toLocaleString()}건
+
+
+
+
+
+
+
+
+ ID
+ 학기
+ 상태
+ 작업
+
+
+
+ {termsQuery.isLoading ? (
+
+
+ 불러오는 중...
+
+
+ ) : termsQuery.isError ? (
+
+
+ 학기 목록을 불러오지 못했습니다.
+
+
+ ) : terms.length === 0 ? (
+
+
+ 등록된 학기가 없습니다.
+
+
+ ) : (
+ terms.map((term) => (
+
+ {term.id}
+ {term.label}
+
+ {term.isCurrent ? (
+
+ 현재 학기
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/features/terms/termValidation.test.ts b/apps/admin/src/components/features/terms/termValidation.test.ts
new file mode 100644
index 00000000..9e91dd80
--- /dev/null
+++ b/apps/admin/src/components/features/terms/termValidation.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+import { normalizeTermName } from "./termValidation";
+
+describe("normalizeTermName", () => {
+ it("trims and accepts YYYY-N term names", () => {
+ expect(normalizeTermName(" 2026-1 ")).toBe("2026-1");
+ });
+
+ it.each(["", "2026", "26-1", "2026-10"])("rejects %s", (value) => {
+ expect(normalizeTermName(value)).toBeNull();
+ });
+});
diff --git a/apps/admin/src/components/features/terms/termValidation.ts b/apps/admin/src/components/features/terms/termValidation.ts
new file mode 100644
index 00000000..d372b2f6
--- /dev/null
+++ b/apps/admin/src/components/features/terms/termValidation.ts
@@ -0,0 +1,6 @@
+const TERM_NAME_PATTERN = /^\d{4}-\d$/;
+
+export function normalizeTermName(value: string): string | null {
+ const normalized = value.trim();
+ return TERM_NAME_PATTERN.test(normalized) ? normalized : null;
+}
diff --git a/apps/admin/src/components/layout/AdminSidebar.tsx b/apps/admin/src/components/layout/AdminSidebar.tsx
index 7003562e..20d620d3 100644
--- a/apps/admin/src/components/layout/AdminSidebar.tsx
+++ b/apps/admin/src/components/layout/AdminSidebar.tsx
@@ -1,4 +1,13 @@
-import { Building2, FileText, FlaskConical, MapPinned, MessageSquare, PlusCircle, UserCheck } from "lucide-react";
+import {
+ Building2,
+ CalendarDays,
+ FileText,
+ FlaskConical,
+ MapPinned,
+ MessageSquare,
+ PlusCircle,
+ UserCheck,
+} from "lucide-react";
import { cn } from "@/lib/utils";
export type ActiveAdminMenu =
@@ -6,6 +15,7 @@ export type ActiveAdminMenu =
| "mentorApplications"
| "regionsCountries"
| "homeUniversities"
+ | "terms"
| "univApplyInfos"
| "bruno"
| "chatSocket";
@@ -15,6 +25,7 @@ const sideMenus = [
{ 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: "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 },
{ key: "chatSocket", label: "채팅 소켓", icon: MessageSquare, to: "/chat-socket" as const },
diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts
index 4d579fae..365ccc93 100644
--- a/apps/admin/src/lib/api/admin.ts
+++ b/apps/admin/src/lib/api/admin.ts
@@ -76,6 +76,11 @@ export interface HomeUniversityPayload {
export interface TermResponse {
id: number;
label: string;
+ isCurrent: boolean;
+}
+
+export interface TermCreatePayload {
+ name: string;
}
export interface UnivApplyInfoFieldResponse {
@@ -176,6 +181,11 @@ export const adminApi = {
getTerms: () => axiosInstance.get("/admin/terms").then((res) => res.data),
+ createTerm: (data: TermCreatePayload) =>
+ axiosInstance.post("/admin/terms", data).then((res) => res.data),
+
+ activateTerm: (id: number) => axiosInstance.patch(`/admin/terms/${id}/activate`).then((res) => res.data),
+
getUnivApplyInfoFields: () =>
axiosInstance.get("/admin/univ-apply-infos/fields").then((res) => res.data),
From f85cf6820708573b25e4336c52d7fa84ac869ea7 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 00:12:50 +0900
Subject: [PATCH 08/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=9E=84=ED=8F=AC=ED=8A=B8=20=ED=95=84?=
=?UTF-8?q?=EB=93=9C=20=EB=A7=A4=ED=95=91=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 64 +++++++++++--------
.../univ-apply-infos/univApplyInfoFields.ts | 30 +++++++++
apps/admin/src/lib/api/admin.ts | 2 +-
3 files changed, 69 insertions(+), 27 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
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 fa16a588..453f8cae 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -7,7 +7,8 @@ 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 UnivApplyInfoFieldResponse, type UnivApplyInfoImportResponse } from "@/lib/api/admin";
+import { adminApi, type UnivApplyInfoImportResponse } from "@/lib/api/admin";
+import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
function extractMarkdownHeaders(markdown: string): string[] {
const lines = markdown.trim().split("\n");
@@ -20,15 +21,15 @@ function extractMarkdownHeaders(markdown: string): string[] {
.filter((h) => h.length > 0);
}
-function buildAutoMappings(headers: string[], fields: UnivApplyInfoFieldResponse): Record {
+function buildAutoMappings(headers: string[], languageTestTypes: string[]): 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;
+ const field = findFieldByHeader(header);
+ if (field) {
+ mappings[header] = field;
continue;
}
- if (fields.languageTestTypes.includes(header)) {
+ if (languageTestTypes.includes(header)) {
mappings[header] = header;
}
}
@@ -90,7 +91,7 @@ export function UnivApplyInfosPageContent() {
toast.error("마크다운 헤더를 파싱할 수 없습니다. 형식을 확인해주세요.");
return;
}
- const auto = fieldsQuery.data ? buildAutoMappings(headers, fieldsQuery.data) : {};
+ const auto = buildAutoMappings(headers, fieldsQuery.data?.languageTestTypes ?? []);
setParsedHeaders(headers);
setColumnMappings(auto);
setImportResult(null);
@@ -123,13 +124,6 @@ export function UnivApplyInfosPageContent() {
const universities = homeUniversitiesQuery.data ?? [];
const terms = termsQuery.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 (
{header}
-
+ {fields?.languageTestTypes.includes(columnMappings[header] ?? "") ? (
+
+ 언어 시험 타입: {columnMappings[header]}
+
+ ) : (
+
+ )}
))}
@@ -273,6 +273,18 @@ export function UnivApplyInfosPageContent() {
>
)}
+ {importResult.createdUniversities.length > 0 && (
+
+
신규 등록된 대학 {importResult.createdUniversities.length}개
+
+ {importResult.createdUniversities.map((name) => (
+ -
+ {name}
+
+ ))}
+
+
+ )}
{importResult.failedRows.length > 0 && (
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
new file mode 100644
index 00000000..6f438c6e
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
@@ -0,0 +1,30 @@
+export const UNIV_APPLY_INFO_FIELDS = [
+ { field: "universityKoreanName", label: "대학 한국어명", aliases: ["대학명", "학교명", "대학교명"] },
+ { field: "universityEnglishName", label: "대학 영어명", aliases: ["영문명", "영어명"] },
+ { field: "universityFormatName", label: "대학 표기명", aliases: ["표기명"] },
+ { field: "universityBackgroundImageUrl", label: "배경 이미지 URL", aliases: ["배경이미지URL", "배경이미지"] },
+ { field: "universityLogoImageUrl", label: "로고 이미지 URL", aliases: ["로고이미지URL", "로고이미지"] },
+ { field: "universityCountryCode", label: "국가 코드", aliases: ["국가코드"] },
+ { field: "universityRegionCode", label: "권역 코드", aliases: ["권역코드"] },
+ { field: "studentCapacity", label: "모집 인원", aliases: ["인원", "모집인원", "정원", "모집정원"] },
+ { field: "tuitionFeeType", label: "등록금 유형", aliases: ["등록금유형", "수업료유형"] },
+ { field: "semesterAvailableForDispatch", label: "파견 가능 학기", aliases: ["파견가능학기", "파견학기"] },
+ { field: "semesterRequirement", label: "학기 요건", aliases: ["학기요건", "재학학기"] },
+ { field: "detailsForLanguage", label: "어학 사항", aliases: ["어학사항", "어학요건상세"] },
+ { field: "gpaRequirement", label: "성적 요건", aliases: ["성적요건", "학점요건", "최소학점"] },
+ { field: "gpaRequirementCriteria", label: "학점 기준", aliases: ["학점기준", "성적기준"] },
+ { field: "detailsForApply", label: "지원 사항", aliases: ["지원사항", "지원안내"] },
+ { field: "detailsForMajor", label: "전공 사항", aliases: ["전공사항", "전공안내"] },
+ { field: "detailsForAccommodation", label: "숙소 사항", aliases: ["숙소사항", "기숙사안내"] },
+ { field: "detailsForEnglishCourse", label: "영어 강좌", aliases: ["영어강좌", "영어강의"] },
+ { field: "details", label: "기타 사항", aliases: ["기타사항", "비고"] },
+] as const;
+
+export type UnivApplyInfoFieldName = (typeof UNIV_APPLY_INFO_FIELDS)[number]["field"];
+
+export function findFieldByHeader(header: string): UnivApplyInfoFieldName | undefined {
+ const matched = UNIV_APPLY_INFO_FIELDS.find(
+ (f) => f.field === header || (f.aliases as readonly string[]).includes(header),
+ );
+ return matched?.field;
+}
diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts
index 365ccc93..2f04bb5c 100644
--- a/apps/admin/src/lib/api/admin.ts
+++ b/apps/admin/src/lib/api/admin.ts
@@ -84,7 +84,6 @@ export interface TermCreatePayload {
}
export interface UnivApplyInfoFieldResponse {
- structuredFields: { field: string; aliases: string[] }[];
languageTestTypes: string[];
}
@@ -98,6 +97,7 @@ export interface UnivApplyInfoImportRequest {
export interface UnivApplyInfoImportResponse {
successCount: number;
failedRows: { rowNumber: number; reason: string }[];
+ createdUniversities: string[];
}
const assignMentorApplicationUniversity = (mentorApplicationId: string | number, universityId: number) =>
From 4b41f18abe0349b39fbf96673b92e4264f2770a3 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 00:32:15 +0900
Subject: [PATCH 09/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=EA=B5=AD=EA=B0=80=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=B3=80=ED=99=98=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 4 +-
.../univ-apply-infos/countryCodeAliases.ts | 113 ++++++++++++++++++
.../univ-apply-infos/univApplyInfoFields.ts | 5 +-
3 files changed, 117 insertions(+), 5 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
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 453f8cae..67017a8d 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -8,6 +8,7 @@ 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 { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
function extractMarkdownHeaders(markdown: string): string[] {
@@ -113,10 +114,11 @@ export function UnivApplyInfosPageContent() {
toast.error("먼저 [파싱] 버튼을 눌러 컬럼을 확인해주세요.");
return;
}
+ const processedMarkdown = preprocessMarkdownCountryCodes(markdown.trim(), columnMappings);
importMutation.mutate({
homeUniversityId: univId,
termId: term,
- markdown: markdown.trim(),
+ markdown: processedMarkdown,
columnMappings,
});
};
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
new file mode 100644
index 00000000..cc47e5cd
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
@@ -0,0 +1,113 @@
+export const COUNTRY_CODE_BY_NAME: Record = {
+ // 한국어
+ 오스트리아: "AT",
+ 호주: "AU",
+ 아제르바이잔: "AZ",
+ 브루나이: "BN",
+ 브라질: "BR",
+ 캐나다: "CA",
+ 스위스: "CH",
+ 중국: "CN",
+ 체코: "CZ",
+ 독일: "DE",
+ 덴마크: "DK",
+ 스페인: "ES",
+ 핀란드: "FI",
+ 프랑스: "FR",
+ 영국: "GB",
+ 홍콩: "HK",
+ 헝가리: "HU",
+ 인도네시아: "ID",
+ 이스라엘: "IL",
+ 이탈리아: "IT",
+ 일본: "JP",
+ 카자흐스탄: "KZ",
+ 리투아니아: "LT",
+ 말레이시아: "MY",
+ 네덜란드: "NL",
+ 노르웨이: "NO",
+ 포르투갈: "PT",
+ 러시아: "RU",
+ 스웨덴: "SE",
+ 싱가포르: "SG",
+ 태국: "TH",
+ 튀르키예: "TR",
+ 대만: "TW",
+ 미국: "US",
+ 우즈베키스탄: "UZ",
+ // 영어 풀 네임
+ Austria: "AT",
+ Australia: "AU",
+ Azerbaijan: "AZ",
+ Brunei: "BN",
+ Brazil: "BR",
+ Canada: "CA",
+ Switzerland: "CH",
+ China: "CN",
+ "Czech Republic": "CZ",
+ Czechia: "CZ",
+ Germany: "DE",
+ Denmark: "DK",
+ Spain: "ES",
+ Finland: "FI",
+ France: "FR",
+ "United Kingdom": "GB",
+ UK: "GB",
+ "Hong Kong": "HK",
+ Hungary: "HU",
+ Indonesia: "ID",
+ Israel: "IL",
+ Italy: "IT",
+ Japan: "JP",
+ Kazakhstan: "KZ",
+ Lithuania: "LT",
+ Malaysia: "MY",
+ Netherlands: "NL",
+ Norway: "NO",
+ Portugal: "PT",
+ Russia: "RU",
+ Sweden: "SE",
+ Singapore: "SG",
+ Thailand: "TH",
+ Turkey: "TR",
+ Türkiye: "TR",
+ Taiwan: "TW",
+ "United States": "US",
+ USA: "US",
+ Uzbekistan: "UZ",
+};
+
+export function resolveCountryCode(value: string): string {
+ return COUNTRY_CODE_BY_NAME[value.trim()] ?? value;
+}
+
+export function preprocessMarkdownCountryCodes(markdown: string, columnMappings: Record): string {
+ const lines = markdown.trim().split("\n");
+ if (lines.length < 3) return markdown;
+
+ const headers = lines[0]
+ .split("|")
+ .map((h) => h.trim())
+ .filter((h) => h.length > 0);
+
+ const countryCodeIndices = headers.reduce((acc, header, i) => {
+ if (columnMappings[header] === "universityCountryCode") acc.push(i);
+ return acc;
+ }, []);
+
+ if (countryCodeIndices.length === 0) return markdown;
+
+ const processedLines = lines.map((line, lineIndex) => {
+ if (lineIndex === 0 || lineIndex === 1) return line;
+ const cells = line.split("|");
+ countryCodeIndices.forEach((colIndex) => {
+ const cellIndex = colIndex + 1;
+ if (cells[cellIndex] !== undefined) {
+ cells[cellIndex] = ` ${resolveCountryCode(cells[cellIndex].trim())} `;
+ }
+ });
+ return cells.join("|");
+ });
+
+ return processedLines.join("\n");
+}
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
index 6f438c6e..6e48de95 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
@@ -2,10 +2,7 @@ export const UNIV_APPLY_INFO_FIELDS = [
{ field: "universityKoreanName", label: "대학 한국어명", aliases: ["대학명", "학교명", "대학교명"] },
{ field: "universityEnglishName", label: "대학 영어명", aliases: ["영문명", "영어명"] },
{ field: "universityFormatName", label: "대학 표기명", aliases: ["표기명"] },
- { field: "universityBackgroundImageUrl", label: "배경 이미지 URL", aliases: ["배경이미지URL", "배경이미지"] },
- { field: "universityLogoImageUrl", label: "로고 이미지 URL", aliases: ["로고이미지URL", "로고이미지"] },
- { field: "universityCountryCode", label: "국가 코드", aliases: ["국가코드"] },
- { field: "universityRegionCode", label: "권역 코드", aliases: ["권역코드"] },
+ { field: "universityCountryCode", label: "국가 코드", aliases: ["국가코드", "국가"] },
{ field: "studentCapacity", label: "모집 인원", aliases: ["인원", "모집인원", "정원", "모집정원"] },
{ field: "tuitionFeeType", label: "등록금 유형", aliases: ["등록금유형", "수업료유형"] },
{ field: "semesterAvailableForDispatch", label: "파견 가능 학기", aliases: ["파견가능학기", "파견학기"] },
From d98b88fbfeb6a4d52d01e93f90e43e4de11b6ca9 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 00:59:23 +0900
Subject: [PATCH 10/33] =?UTF-8?q?=F0=9F=94=A7=20vinext=20navigation=20alia?=
=?UTF-8?q?s=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/admin/vite.config.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
index 885e34b4..c8f75ce8 100644
--- a/apps/admin/vite.config.ts
+++ b/apps/admin/vite.config.ts
@@ -4,10 +4,14 @@ import { nitro } from "nitro/vite";
import vinext from "vinext";
import { defineConfig } from "vite";
+const vinextNavShim = fileURLToPath(new URL("node_modules/vinext/dist/shims/navigation.js", import.meta.url));
+
const config = defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
+ "next/navigation": vinextNavShim,
+ "next/navigation.js": vinextNavShim,
},
tsconfigPaths: true,
},
From adcbb59ef7deb08f1c5fd865206280f8efc5d3e0 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 01:00:04 +0900
Subject: [PATCH 11/33] =?UTF-8?q?=F0=9F=93=A6=20next.js=20=EB=B2=84?=
=?UTF-8?q?=EC=A0=84=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/package.json | 2 +-
pnpm-lock.yaml | 900 +++++++++++++++++++++++++++++++++++-------
2 files changed, 768 insertions(+), 134 deletions(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index 14488662..f0e883ad 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -38,7 +38,7 @@
"linkify-react": "^4.3.2",
"linkifyjs": "^4.3.2",
"lucide-react": "^0.479.0",
- "next": "^16.2.6",
+ "next": "^16.2.9",
"next-render-analyzer": "^0.1.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a740e0ad..8d70d9f6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,7 +40,7 @@ importers:
version: 7.2.1
'@tailwindcss/vite':
specifier: ^4.0.6
- version: 4.1.18(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ version: 4.1.18(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
'@tanstack/react-query':
specifier: ^5.84.1
version: 5.90.19(react@19.2.6)
@@ -92,19 +92,19 @@ importers:
version: 1.5.4
'@vitejs/plugin-react':
specifier: ^6.0.2
- version: 6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ version: 6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc':
specifier: ^0.5.26
- version: 0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ version: 0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
jsdom:
specifier: ^27.0.0
version: 27.4.0
nitro:
specifier: 3.0.260522-beta
- version: 3.0.260522-beta(chokidar@3.6.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.55.1)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ version: 3.0.260522-beta(chokidar@3.6.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.55.1)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
react-server-dom-webpack:
specifier: ^19.2.6
- version: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2))
+ version: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0))
srvx:
specifier: ^0.11.15
version: 0.11.15
@@ -113,13 +113,13 @@ importers:
version: 5.9.3
vinext:
specifier: ^0.0.51
- version: 0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.2.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ version: 0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
vite:
specifier: ^8.0.14
- version: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ version: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^3.0.5
- version: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ version: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
web-vitals:
specifier: ^5.1.0
version: 5.1.0
@@ -149,7 +149,7 @@ importers:
version: 2.20.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@sentry/nextjs':
specifier: ^10.22.0
- version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1)
+ version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))
'@solid-connect/ai-inspector':
specifier: workspace:^
version: link:../../packages/ai-inspector
@@ -264,7 +264,7 @@ importers:
version: 5.2.2(react-hook-form@7.71.1(react@19.2.6))
'@next/third-parties':
specifier: ^16.2.6
- version: 16.2.6(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 16.2.6(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox':
specifier: ^1.1.4
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -282,7 +282,7 @@ importers:
version: 2.20.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@sentry/nextjs':
specifier: ^10.22.0
- version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1)
+ version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.6))
'@solid-connect/ai-inspector':
specifier: workspace:^
version: link:../../packages/ai-inspector
@@ -300,7 +300,7 @@ importers:
version: 3.13.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/speed-insights':
specifier: ^1.3.1
- version: 1.3.1(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 1.3.1(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
axios:
specifier: ^1.6.7
version: 1.13.2
@@ -323,8 +323,8 @@ importers:
specifier: ^0.479.0
version: 0.479.0(react@19.2.6)
next:
- specifier: ^16.2.6
- version: 16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: ^16.2.9
+ version: 16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-render-analyzer:
specifier: ^0.1.2
version: 0.1.2
@@ -1255,6 +1255,9 @@ packages:
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.1':
+ resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
+
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -1264,156 +1267,312 @@ packages:
cpu: [ppc64]
os: [aix]
+ '@esbuild/aix-ppc64@0.27.7':
+ resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm64@0.27.7':
+ resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
+ '@esbuild/android-arm@0.27.7':
+ resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
+ '@esbuild/android-x64@0.27.7':
+ resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-arm64@0.27.7':
+ resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
+ '@esbuild/darwin-x64@0.27.7':
+ resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-arm64@0.27.7':
+ resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.27.7':
+ resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm64@0.27.7':
+ resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
+ '@esbuild/linux-arm@0.27.7':
+ resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-ia32@0.27.7':
+ resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-loong64@0.27.7':
+ resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-mips64el@0.27.7':
+ resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-ppc64@0.27.7':
+ resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-riscv64@0.27.7':
+ resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-s390x@0.27.7':
+ resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
+ '@esbuild/linux-x64@0.27.7':
+ resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
+ '@esbuild/netbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.27.7':
+ resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
+ '@esbuild/openbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.27.7':
+ resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
+ '@esbuild/openharmony-arm64@0.27.7':
+ resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
+ '@esbuild/sunos-x64@0.27.7':
+ resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-arm64@0.27.7':
+ resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-ia32@0.27.7':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
+ '@esbuild/win32-x64@0.27.7':
+ resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@exodus/bytes@1.11.0':
resolution: {integrity: sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1693,54 +1852,105 @@ packages:
'@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
+ '@next/env@16.2.9':
+ resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==}
+
'@next/swc-darwin-arm64@16.2.6':
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
+ '@next/swc-darwin-arm64@16.2.9':
+ resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
'@next/swc-darwin-x64@16.2.6':
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
+ '@next/swc-darwin-x64@16.2.9':
+ resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
'@next/swc-linux-arm64-gnu@16.2.6':
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ '@next/swc-linux-arm64-gnu@16.2.9':
+ resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ '@next/swc-linux-arm64-musl@16.2.9':
+ resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ '@next/swc-linux-x64-gnu@16.2.9':
+ resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ '@next/swc-linux-x64-musl@16.2.9':
+ resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
+ '@next/swc-win32-arm64-msvc@16.2.9':
+ resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
'@next/swc-win32-x64-msvc@16.2.6':
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
+ '@next/swc-win32-x64-msvc@16.2.9':
+ resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
'@next/third-parties@16.2.6':
resolution: {integrity: sha512-PDPIPVj1NX6Taxsl8OJteAUJ7iwR+QrokwWig68eh0cOmuNjC6MBL+ZzBjO8Bv0n/HOSqjGArZpM5KMSUxm+MQ==}
peerDependencies:
@@ -3006,6 +3216,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
'@types/google.maps@3.58.1':
resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==}
@@ -3027,6 +3240,9 @@ packages:
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
+ '@types/node@20.19.43':
+ resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
@@ -3235,6 +3451,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -3259,6 +3480,9 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -3359,6 +3583,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ baseline-browser-mapping@2.10.37:
+ resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -3384,6 +3613,11 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -3416,6 +3650,9 @@ packages:
caniuse-lite@1.0.30001764:
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
+ caniuse-lite@1.0.30001799:
+ resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -3741,6 +3978,9 @@ packages:
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+ electron-to-chromium@1.5.372:
+ resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
+
emoji-regex-xs@2.0.1:
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
engines: {node: '>=10.0.0'}
@@ -3758,6 +3998,10 @@ packages:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
+ enhanced-resolve@5.24.0:
+ resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==}
+ engines: {node: '>=10.13.0'}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -3815,6 +4059,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ esbuild@0.27.7:
+ resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -3888,6 +4137,9 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+ fast-uri@3.1.2:
+ resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
+
fast-xml-builder@1.1.4:
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
@@ -4012,8 +4264,8 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
- get-tsconfig@4.13.1:
- resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==}
+ get-tsconfig@4.14.0:
+ resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
git-raw-commits@4.0.0:
resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
@@ -4477,8 +4729,8 @@ packages:
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
- loader-runner@4.3.1:
- resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
+ loader-runner@4.3.2:
+ resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==}
engines: {node: '>=6.11.5'}
locate-path@6.0.0:
@@ -4695,6 +4947,27 @@ packages:
sass:
optional: true
+ next@16.2.9:
+ resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
+ engines: {node: '>=20.9.0'}
+ hasBin: true
+ peerDependencies:
+ '@opentelemetry/api': ^1.1.0
+ '@playwright/test': ^1.51.1
+ babel-plugin-react-compiler: '*'
+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ sass: ^1.3.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@playwright/test':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ sass:
+ optional: true
+
nf3@0.3.17:
resolution: {integrity: sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ==}
@@ -4757,6 +5030,10 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ node-releases@2.0.47:
+ resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
+ engines: {node: '>=18'}
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -4995,9 +5272,6 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
- randombytes@2.1.0:
- resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
-
react-dom@19.2.6:
resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
@@ -5180,8 +5454,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
- serialize-javascript@6.0.2:
- resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
+ semver@7.8.4:
+ resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
+ engines: {node: '>=10'}
+ hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
@@ -5350,28 +5626,59 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
teeny-request@9.0.0:
resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==}
engines: {node: '>=14'}
- terser-webpack-plugin@5.3.16:
- resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==}
+ terser-webpack-plugin@5.6.1:
+ resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==}
engines: {node: '>= 10.13.0'}
peerDependencies:
+ '@minify-html/node': '*'
'@swc/core': '*'
+ '@swc/css': '*'
+ '@swc/html': '*'
+ clean-css: '*'
+ cssnano: '*'
+ csso: '*'
esbuild: '*'
+ html-minifier-terser: '*'
+ lightningcss: '*'
+ postcss: '*'
uglify-js: '*'
webpack: ^5.1.0
peerDependenciesMeta:
+ '@minify-html/node':
+ optional: true
'@swc/core':
optional: true
+ '@swc/css':
+ optional: true
+ '@swc/html':
+ optional: true
+ clean-css:
+ optional: true
+ cssnano:
+ optional: true
+ csso:
+ optional: true
esbuild:
optional: true
+ html-minifier-terser:
+ optional: true
+ lightningcss:
+ optional: true
+ postcss:
+ optional: true
uglify-js:
optional: true
- terser@5.46.0:
- resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==}
+ terser@5.48.0:
+ resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==}
engines: {node: '>=10'}
hasBin: true
@@ -5837,8 +6144,8 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
- watchpack@2.5.1:
- resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
+ watchpack@2.5.2:
+ resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
web-streams-polyfill@3.3.3:
@@ -5867,6 +6174,10 @@ packages:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
engines: {node: '>=10.13.0'}
+ webpack-sources@3.5.0:
+ resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==}
+ engines: {node: '>=10.13.0'}
+
webpack-virtual-modules@0.5.0:
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
@@ -6995,6 +7306,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
@@ -7003,81 +7319,159 @@ snapshots:
'@esbuild/aix-ppc64@0.27.2':
optional: true
+ '@esbuild/aix-ppc64@0.27.7':
+ optional: true
+
'@esbuild/android-arm64@0.27.2':
optional: true
+ '@esbuild/android-arm64@0.27.7':
+ optional: true
+
'@esbuild/android-arm@0.27.2':
optional: true
+ '@esbuild/android-arm@0.27.7':
+ optional: true
+
'@esbuild/android-x64@0.27.2':
optional: true
+ '@esbuild/android-x64@0.27.7':
+ optional: true
+
'@esbuild/darwin-arm64@0.27.2':
optional: true
+ '@esbuild/darwin-arm64@0.27.7':
+ optional: true
+
'@esbuild/darwin-x64@0.27.2':
optional: true
+ '@esbuild/darwin-x64@0.27.7':
+ optional: true
+
'@esbuild/freebsd-arm64@0.27.2':
optional: true
+ '@esbuild/freebsd-arm64@0.27.7':
+ optional: true
+
'@esbuild/freebsd-x64@0.27.2':
optional: true
+ '@esbuild/freebsd-x64@0.27.7':
+ optional: true
+
'@esbuild/linux-arm64@0.27.2':
optional: true
+ '@esbuild/linux-arm64@0.27.7':
+ optional: true
+
'@esbuild/linux-arm@0.27.2':
optional: true
+ '@esbuild/linux-arm@0.27.7':
+ optional: true
+
'@esbuild/linux-ia32@0.27.2':
optional: true
+ '@esbuild/linux-ia32@0.27.7':
+ optional: true
+
'@esbuild/linux-loong64@0.27.2':
optional: true
+ '@esbuild/linux-loong64@0.27.7':
+ optional: true
+
'@esbuild/linux-mips64el@0.27.2':
optional: true
+ '@esbuild/linux-mips64el@0.27.7':
+ optional: true
+
'@esbuild/linux-ppc64@0.27.2':
optional: true
+ '@esbuild/linux-ppc64@0.27.7':
+ optional: true
+
'@esbuild/linux-riscv64@0.27.2':
optional: true
+ '@esbuild/linux-riscv64@0.27.7':
+ optional: true
+
'@esbuild/linux-s390x@0.27.2':
optional: true
+ '@esbuild/linux-s390x@0.27.7':
+ optional: true
+
'@esbuild/linux-x64@0.27.2':
optional: true
+ '@esbuild/linux-x64@0.27.7':
+ optional: true
+
'@esbuild/netbsd-arm64@0.27.2':
optional: true
+ '@esbuild/netbsd-arm64@0.27.7':
+ optional: true
+
'@esbuild/netbsd-x64@0.27.2':
optional: true
+ '@esbuild/netbsd-x64@0.27.7':
+ optional: true
+
'@esbuild/openbsd-arm64@0.27.2':
optional: true
+ '@esbuild/openbsd-arm64@0.27.7':
+ optional: true
+
'@esbuild/openbsd-x64@0.27.2':
optional: true
+ '@esbuild/openbsd-x64@0.27.7':
+ optional: true
+
'@esbuild/openharmony-arm64@0.27.2':
optional: true
+ '@esbuild/openharmony-arm64@0.27.7':
+ optional: true
+
'@esbuild/sunos-x64@0.27.2':
optional: true
+ '@esbuild/sunos-x64@0.27.7':
+ optional: true
+
'@esbuild/win32-arm64@0.27.2':
optional: true
+ '@esbuild/win32-arm64@0.27.7':
+ optional: true
+
'@esbuild/win32-ia32@0.27.2':
optional: true
+ '@esbuild/win32-ia32@0.27.7':
+ optional: true
+
'@esbuild/win32-x64@0.27.2':
optional: true
+ '@esbuild/win32-x64@0.27.7':
+ optional: true
+
'@exodus/bytes@1.11.0': {}
'@fastify/busboy@3.2.0': {}
@@ -7307,7 +7701,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.10.0
+ '@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -7371,36 +7765,68 @@ snapshots:
'@next/env@16.2.6': {}
+ '@next/env@16.2.9': {}
+
'@next/swc-darwin-arm64@16.2.6':
optional: true
+ '@next/swc-darwin-arm64@16.2.9':
+ optional: true
+
'@next/swc-darwin-x64@16.2.6':
optional: true
+ '@next/swc-darwin-x64@16.2.9':
+ optional: true
+
'@next/swc-linux-arm64-gnu@16.2.6':
optional: true
+ '@next/swc-linux-arm64-gnu@16.2.9':
+ optional: true
+
'@next/swc-linux-arm64-musl@16.2.6':
optional: true
+ '@next/swc-linux-arm64-musl@16.2.9':
+ optional: true
+
'@next/swc-linux-x64-gnu@16.2.6':
optional: true
+ '@next/swc-linux-x64-gnu@16.2.9':
+ optional: true
+
'@next/swc-linux-x64-musl@16.2.6':
optional: true
+ '@next/swc-linux-x64-musl@16.2.9':
+ optional: true
+
'@next/swc-win32-arm64-msvc@16.2.6':
optional: true
+ '@next/swc-win32-arm64-msvc@16.2.9':
+ optional: true
+
'@next/swc-win32-x64-msvc@16.2.6':
optional: true
+ '@next/swc-win32-x64-msvc@16.2.9':
+ optional: true
+
'@next/third-parties@16.2.6(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
dependencies:
next: 16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
third-party-capital: 1.0.20
+ '@next/third-parties@16.2.6(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ next: 16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react: 19.2.6
+ third-party-capital: 1.0.20
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -8267,7 +8693,7 @@ snapshots:
'@sentry/core@10.34.0': {}
- '@sentry/nextjs@10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1)':
+ '@sentry/nextjs@10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
@@ -8279,7 +8705,7 @@ snapshots:
'@sentry/opentelemetry': 10.34.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)
'@sentry/react': 10.34.0(react@19.2.6)
'@sentry/vercel-edge': 10.34.0
- '@sentry/webpack-plugin': 4.6.2(webpack@5.104.1)
+ '@sentry/webpack-plugin': 4.6.2(webpack@5.104.1(postcss@8.5.15))
next: 16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
rollup: 4.55.1
stacktrace-parser: 0.1.11
@@ -8292,6 +8718,31 @@ snapshots:
- supports-color
- webpack
+ '@sentry/nextjs@10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.6))':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+ '@rollup/plugin-commonjs': 28.0.1(rollup@4.55.1)
+ '@sentry-internal/browser-utils': 10.34.0
+ '@sentry/bundler-plugin-core': 4.6.2
+ '@sentry/core': 10.34.0
+ '@sentry/node': 10.34.0
+ '@sentry/opentelemetry': 10.34.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)
+ '@sentry/react': 10.34.0(react@19.2.6)
+ '@sentry/vercel-edge': 10.34.0
+ '@sentry/webpack-plugin': 4.6.2(webpack@5.104.1(postcss@8.5.6))
+ next: 16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ rollup: 4.55.1
+ stacktrace-parser: 0.1.11
+ transitivePeerDependencies:
+ - '@opentelemetry/context-async-hooks'
+ - '@opentelemetry/core'
+ - '@opentelemetry/sdk-trace-base'
+ - encoding
+ - react
+ - supports-color
+ - webpack
+
'@sentry/node-core@10.34.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)':
dependencies:
'@apm-js-collab/tracing-hooks': 0.3.1
@@ -8369,12 +8820,22 @@ snapshots:
'@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0)
'@sentry/core': 10.34.0
- '@sentry/webpack-plugin@4.6.2(webpack@5.104.1)':
+ '@sentry/webpack-plugin@4.6.2(webpack@5.104.1(postcss@8.5.15))':
+ dependencies:
+ '@sentry/bundler-plugin-core': 4.6.2
+ unplugin: 1.0.1
+ uuid: 9.0.1
+ webpack: 5.104.1(postcss@8.5.15)
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
+ '@sentry/webpack-plugin@4.6.2(webpack@5.104.1(postcss@8.5.6))':
dependencies:
'@sentry/bundler-plugin-core': 4.6.2
unplugin: 1.0.1
uuid: 9.0.1
- webpack: 5.104.1
+ webpack: 5.104.1(postcss@8.5.6)
transitivePeerDependencies:
- encoding
- supports-color
@@ -8546,12 +9007,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
- '@tailwindcss/vite@4.1.18(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
+ '@tailwindcss/vite@4.1.18(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
'@tanstack/query-core@5.90.19': {}
@@ -8630,15 +9091,17 @@ snapshots:
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/eslint@9.6.1':
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@types/estree@1.0.8': {}
+ '@types/estree@1.0.9': {}
+
'@types/google.maps@3.58.1': {}
'@types/json-schema@7.0.15': {}
@@ -8661,6 +9124,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/node@20.19.43':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@22.19.7':
dependencies:
undici-types: 6.21.0
@@ -8712,13 +9179,13 @@ snapshots:
dependencies:
unpic: 4.2.2
- '@unpic/react@1.0.2(next@16.2.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@unpic/react@1.0.2(next@16.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@unpic/core': 1.0.3
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
- next: 16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/og@0.8.6':
dependencies:
@@ -8730,14 +9197,19 @@ snapshots:
next: 16.2.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
- '@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
+ '@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ optionalDependencies:
+ next: 16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react: 19.2.6
+
+ '@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
babel-plugin-react-compiler: 1.0.0
- '@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
+ '@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.18
es-module-lexer: 2.1.0
@@ -8748,10 +9220,10 @@ snapshots:
srvx: 0.11.15
strip-literal: 3.1.0
turbo-stream: 3.2.0
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
- vitefu: 1.1.3(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
+ vitefu: 1.1.3(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
optionalDependencies:
- react-server-dom-webpack: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2))
+ react-server-dom-webpack: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0))
'@vitest/expect@3.2.4':
dependencies:
@@ -8761,13 +9233,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
- '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
+ '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -8889,9 +9361,9 @@ snapshots:
dependencies:
acorn: 8.15.0
- acorn-import-phases@1.0.4(acorn@8.15.0):
+ acorn-import-phases@1.0.4(acorn@8.17.0):
dependencies:
- acorn: 8.15.0
+ acorn: 8.17.0
acorn-loose@8.5.2:
dependencies:
@@ -8903,6 +9375,8 @@ snapshots:
acorn@8.15.0: {}
+ acorn@8.17.0: {}
+
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@@ -8911,13 +9385,13 @@ snapshots:
agent-base@7.1.4: {}
- ajv-formats@2.1.1(ajv@8.17.1):
+ ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies:
- ajv: 8.17.1
+ ajv: 8.20.0
- ajv-keywords@5.1.0(ajv@8.17.1):
+ ajv-keywords@5.1.0(ajv@8.20.0):
dependencies:
- ajv: 8.17.1
+ ajv: 8.20.0
fast-deep-equal: 3.1.3
ajv@8.17.1:
@@ -8927,6 +9401,13 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.2
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@@ -9034,6 +9515,8 @@ snapshots:
baseline-browser-mapping@2.10.32: {}
+ baseline-browser-mapping@2.10.37: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -9060,6 +9543,14 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.37
+ caniuse-lite: 1.0.30001799
+ electron-to-chromium: 1.5.372
+ node-releases: 2.0.47
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
@@ -9081,6 +9572,8 @@ snapshots:
caniuse-lite@1.0.30001764: {}
+ caniuse-lite@1.0.30001799: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -9371,6 +9864,8 @@ snapshots:
electron-to-chromium@1.5.267: {}
+ electron-to-chromium@1.5.372: {}
+
emoji-regex-xs@2.0.1: {}
emoji-regex@8.0.0: {}
@@ -9387,6 +9882,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ enhanced-resolve@5.24.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
entities@4.5.0: {}
entities@6.0.1: {}
@@ -9452,6 +9952,36 @@ snapshots:
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
+ esbuild@0.27.7:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.7
+ '@esbuild/android-arm': 0.27.7
+ '@esbuild/android-arm64': 0.27.7
+ '@esbuild/android-x64': 0.27.7
+ '@esbuild/darwin-arm64': 0.27.7
+ '@esbuild/darwin-x64': 0.27.7
+ '@esbuild/freebsd-arm64': 0.27.7
+ '@esbuild/freebsd-x64': 0.27.7
+ '@esbuild/linux-arm': 0.27.7
+ '@esbuild/linux-arm64': 0.27.7
+ '@esbuild/linux-ia32': 0.27.7
+ '@esbuild/linux-loong64': 0.27.7
+ '@esbuild/linux-mips64el': 0.27.7
+ '@esbuild/linux-ppc64': 0.27.7
+ '@esbuild/linux-riscv64': 0.27.7
+ '@esbuild/linux-s390x': 0.27.7
+ '@esbuild/linux-x64': 0.27.7
+ '@esbuild/netbsd-arm64': 0.27.7
+ '@esbuild/netbsd-x64': 0.27.7
+ '@esbuild/openbsd-arm64': 0.27.7
+ '@esbuild/openbsd-x64': 0.27.7
+ '@esbuild/openharmony-arm64': 0.27.7
+ '@esbuild/sunos-x64': 0.27.7
+ '@esbuild/win32-arm64': 0.27.7
+ '@esbuild/win32-ia32': 0.27.7
+ '@esbuild/win32-x64': 0.27.7
+ optional: true
+
escalade@3.2.0: {}
escape-html@1.0.3: {}
@@ -9506,6 +10036,8 @@ snapshots:
fast-uri@3.1.0: {}
+ fast-uri@3.1.2: {}
+
fast-xml-builder@1.1.4:
dependencies:
path-expression-matcher: 1.2.0
@@ -9678,7 +10210,7 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
- get-tsconfig@4.13.1:
+ get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
optional: true
@@ -9933,7 +10465,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- '@types/node': 20.19.30
+ '@types/node': 20.19.43
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -10145,7 +10677,7 @@ snapshots:
linkifyjs@4.3.2: {}
- loader-runner@4.3.1: {}
+ loader-runner@4.3.2: {}
locate-path@6.0.0:
dependencies:
@@ -10325,9 +10857,35 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ dependencies:
+ '@next/env': 16.2.9
+ '@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.10.37
+ caniuse-lite: 1.0.30001799
+ postcss: 8.4.31
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.6)
+ optionalDependencies:
+ '@next/swc-darwin-arm64': 16.2.9
+ '@next/swc-darwin-x64': 16.2.9
+ '@next/swc-linux-arm64-gnu': 16.2.9
+ '@next/swc-linux-arm64-musl': 16.2.9
+ '@next/swc-linux-x64-gnu': 16.2.9
+ '@next/swc-linux-x64-musl': 16.2.9
+ '@next/swc-win32-arm64-msvc': 16.2.9
+ '@next/swc-win32-x64-msvc': 16.2.9
+ '@opentelemetry/api': 1.9.0
+ babel-plugin-react-compiler: 1.0.0
+ sharp: 0.34.5
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+
nf3@0.3.17: {}
- nitro@3.0.260522-beta(chokidar@3.6.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.55.1)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
+ nitro@3.0.260522-beta(chokidar@3.6.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.55.1)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
consola: 3.4.2
crossws: 0.4.5(srvx@0.11.15)
@@ -10347,7 +10905,7 @@ snapshots:
dotenv: 16.6.1
jiti: 2.6.1
rollup: 4.55.1
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -10400,6 +10958,8 @@ snapshots:
node-releases@2.0.27: {}
+ node-releases@2.0.47: {}
+
normalize-path@3.0.0: {}
nth-check@2.1.1:
@@ -10611,10 +11171,6 @@ snapshots:
queue-microtask@1.2.3: {}
- randombytes@2.1.0:
- dependencies:
- safe-buffer: 5.2.1
-
react-dom@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
@@ -10652,13 +11208,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.15
- react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)):
+ react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)):
dependencies:
acorn-loose: 8.5.2
neo-async: 2.6.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- webpack: 5.104.1(esbuild@0.27.2)
+ webpack: 5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)
webpack-sources: 3.3.3
react-style-singleton@2.2.3(@types/react@19.2.15)(react@19.2.6):
@@ -10833,23 +11389,22 @@ snapshots:
schema-utils@4.3.3:
dependencies:
'@types/json-schema': 7.0.15
- ajv: 8.17.1
- ajv-formats: 2.1.1(ajv@8.17.1)
- ajv-keywords: 5.1.0(ajv@8.17.1)
+ ajv: 8.20.0
+ ajv-formats: 2.1.1(ajv@8.20.0)
+ ajv-keywords: 5.1.0(ajv@8.20.0)
semver@6.3.1: {}
semver@7.7.3: {}
- serialize-javascript@6.0.2:
- dependencies:
- randombytes: 2.1.0
+ semver@7.8.4:
+ optional: true
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.7.3
+ semver: 7.8.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
@@ -11062,6 +11617,8 @@ snapshots:
tapable@2.3.0: {}
+ tapable@2.3.3: {}
+
teeny-request@9.0.0:
dependencies:
http-proxy-agent: 5.0.0
@@ -11074,30 +11631,41 @@ snapshots:
- supports-color
optional: true
- terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.104.1(esbuild@0.27.2)):
+ terser-webpack-plugin@5.6.1(esbuild@0.27.7)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
- serialize-javascript: 6.0.2
- terser: 5.46.0
- webpack: 5.104.1(esbuild@0.27.2)
+ terser: 5.48.0
+ webpack: 5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)
optionalDependencies:
- esbuild: 0.27.2
+ esbuild: 0.27.7
+ lightningcss: 1.32.0
- terser-webpack-plugin@5.3.16(webpack@5.104.1):
+ terser-webpack-plugin@5.6.1(postcss@8.5.15)(webpack@5.104.1(postcss@8.5.15)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
- serialize-javascript: 6.0.2
- terser: 5.46.0
- webpack: 5.104.1
+ terser: 5.48.0
+ webpack: 5.104.1(postcss@8.5.15)
+ optionalDependencies:
+ postcss: 8.5.15
- terser@5.46.0:
+ terser-webpack-plugin@5.6.1(postcss@8.5.6)(webpack@5.104.1(postcss@8.5.6)):
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ jest-worker: 27.5.1
+ schema-utils: 4.3.3
+ terser: 5.48.0
+ webpack: 5.104.1(postcss@8.5.6)
+ optionalDependencies:
+ postcss: 8.5.6
+
+ terser@5.48.0:
dependencies:
'@jridgewell/source-map': 0.3.11
- acorn: 8.15.0
+ acorn: 8.17.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -11171,8 +11739,8 @@ snapshots:
tsx@4.21.0:
dependencies:
- esbuild: 0.27.2
- get-tsconfig: 4.13.1
+ esbuild: 0.27.7
+ get-tsconfig: 4.14.0
optionalDependencies:
fsevents: 2.3.3
optional: true
@@ -11256,6 +11824,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
@@ -11290,35 +11864,35 @@ snapshots:
uuid@9.0.1: {}
- vinext@0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.2.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
+ vinext@0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
- '@unpic/react': 1.0.2(next@16.2.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@unpic/react': 1.0.2(next@16.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/og': 0.8.6
- '@vitejs/plugin-react': 6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitejs/plugin-react': 6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
image-size: 2.0.2
ipaddr.js: 2.4.0
magic-string: 0.30.21
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-commonjs: 0.10.4
- vite-tsconfig-paths: 6.1.1(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ vite-tsconfig-paths: 6.1.1(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
web-vitals: 4.2.4
optionalDependencies:
- '@vitejs/plugin-rsc': 0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
- react-server-dom-webpack: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2))
+ '@vitejs/plugin-rsc': 0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0)))(react@19.2.6)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
+ react-server-dom-webpack: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0))
transitivePeerDependencies:
- next
- supports-color
- typescript
- vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
+ vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -11335,28 +11909,28 @@ snapshots:
vite-plugin-commonjs@0.10.4:
dependencies:
- acorn: 8.15.0
+ acorn: 8.17.0
magic-string: 0.30.21
vite-plugin-dynamic-import: 1.6.0
vite-plugin-dynamic-import@1.6.0:
dependencies:
- acorn: 8.15.0
+ acorn: 8.17.0
es-module-lexer: 1.7.0
fast-glob: 3.3.3
magic-string: 0.30.21
- vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
+ vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
- typescript
- vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
+ vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -11369,11 +11943,11 @@ snapshots:
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.32.0
- terser: 5.46.0
+ terser: 5.48.0
tsx: 4.21.0
yaml: 2.8.2
- vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
+ vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -11382,22 +11956,22 @@ snapshots:
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 22.19.7
- esbuild: 0.27.2
+ esbuild: 0.27.7
fsevents: 2.3.3
jiti: 2.6.1
- terser: 5.46.0
+ terser: 5.48.0
tsx: 4.21.0
yaml: 2.8.2
- vitefu@1.1.3(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
+ vitefu@1.1.3(vite@8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)):
optionalDependencies:
- vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.14(@types/node@22.19.7)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
- vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
+ vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -11415,8 +11989,8 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
- vite-node: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
+ vite-node: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.7
@@ -11439,9 +12013,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
- watchpack@2.5.1:
+ watchpack@2.5.2:
dependencies:
- glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
web-streams-polyfill@3.3.3: {}
@@ -11475,70 +12048,131 @@ snapshots:
webpack-sources@3.3.3: {}
+ webpack-sources@3.5.0: {}
+
webpack-virtual-modules@0.5.0: {}
- webpack@5.104.1:
+ webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0):
dependencies:
'@types/eslint-scope': 3.7.7
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
- acorn: 8.15.0
- acorn-import-phases: 1.0.4(acorn@8.15.0)
- browserslist: 4.28.1
+ acorn: 8.17.0
+ acorn-import-phases: 1.0.4(acorn@8.17.0)
+ browserslist: 4.28.2
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.18.4
+ enhanced-resolve: 5.24.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
- loader-runner: 4.3.1
+ loader-runner: 4.3.2
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
- tapable: 2.3.0
- terser-webpack-plugin: 5.3.16(webpack@5.104.1)
- watchpack: 2.5.1
- webpack-sources: 3.3.3
+ tapable: 2.3.3
+ terser-webpack-plugin: 5.6.1(esbuild@0.27.7)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.7)(lightningcss@1.32.0))
+ watchpack: 2.5.2
+ webpack-sources: 3.5.0
transitivePeerDependencies:
+ - '@minify-html/node'
- '@swc/core'
+ - '@swc/css'
+ - '@swc/html'
+ - clean-css
+ - cssnano
+ - csso
- esbuild
+ - html-minifier-terser
+ - lightningcss
+ - postcss
- uglify-js
- webpack@5.104.1(esbuild@0.27.2):
+ webpack@5.104.1(postcss@8.5.15):
dependencies:
'@types/eslint-scope': 3.7.7
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
- acorn: 8.15.0
- acorn-import-phases: 1.0.4(acorn@8.15.0)
- browserslist: 4.28.1
+ acorn: 8.17.0
+ acorn-import-phases: 1.0.4(acorn@8.17.0)
+ browserslist: 4.28.2
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.18.4
+ enhanced-resolve: 5.24.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
- loader-runner: 4.3.1
+ loader-runner: 4.3.2
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
- tapable: 2.3.0
- terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.104.1(esbuild@0.27.2))
- watchpack: 2.5.1
- webpack-sources: 3.3.3
+ tapable: 2.3.3
+ terser-webpack-plugin: 5.6.1(postcss@8.5.15)(webpack@5.104.1(postcss@8.5.15))
+ watchpack: 2.5.2
+ webpack-sources: 3.5.0
+ transitivePeerDependencies:
+ - '@minify-html/node'
+ - '@swc/core'
+ - '@swc/css'
+ - '@swc/html'
+ - clean-css
+ - cssnano
+ - csso
+ - esbuild
+ - html-minifier-terser
+ - lightningcss
+ - postcss
+ - uglify-js
+
+ webpack@5.104.1(postcss@8.5.6):
+ dependencies:
+ '@types/eslint-scope': 3.7.7
+ '@types/estree': 1.0.9
+ '@types/json-schema': 7.0.15
+ '@webassemblyjs/ast': 1.14.1
+ '@webassemblyjs/wasm-edit': 1.14.1
+ '@webassemblyjs/wasm-parser': 1.14.1
+ acorn: 8.17.0
+ acorn-import-phases: 1.0.4(acorn@8.17.0)
+ browserslist: 4.28.2
+ chrome-trace-event: 1.0.4
+ enhanced-resolve: 5.24.0
+ es-module-lexer: 2.1.0
+ eslint-scope: 5.1.1
+ events: 3.3.0
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+ json-parse-even-better-errors: 2.3.1
+ loader-runner: 4.3.2
+ mime-types: 2.1.35
+ neo-async: 2.6.2
+ schema-utils: 4.3.3
+ tapable: 2.3.3
+ terser-webpack-plugin: 5.6.1(postcss@8.5.6)(webpack@5.104.1(postcss@8.5.6))
+ watchpack: 2.5.2
+ webpack-sources: 3.5.0
transitivePeerDependencies:
+ - '@minify-html/node'
- '@swc/core'
+ - '@swc/css'
+ - '@swc/html'
+ - clean-css
+ - cssnano
+ - csso
- esbuild
+ - html-minifier-terser
+ - lightningcss
+ - postcss
- uglify-js
websocket-driver@0.7.4:
From db5f03999456764e532612abe9aa4874d3c9ffc7 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 01:00:16 +0900
Subject: [PATCH 12/33] =?UTF-8?q?=E2=9C=A8=20=EC=9E=84=ED=8F=AC=ED=8A=B8?=
=?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=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
---
.../UnivApplyInfosPageContent.tsx | 127 +++++++++++++++++-
1 file changed, 122 insertions(+), 5 deletions(-)
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 67017a8d..96793d98 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -37,6 +37,32 @@ function buildAutoMappings(headers: string[], languageTestTypes: string[]): Reco
return mappings;
}
+function parsePreviewRows(markdown: string, columnMappings: Record): Array> {
+ const processed = preprocessMarkdownCountryCodes(markdown, columnMappings);
+ const lines = processed.trim().split("\n");
+ if (lines.length < 3) return [];
+
+ const rawHeaders = lines[0]
+ .split("|")
+ .slice(1, -1)
+ .map((h) => h.trim());
+
+ return lines.slice(2).flatMap((line) => {
+ const cells = line
+ .split("|")
+ .slice(1, -1)
+ .map((c) => c.trim());
+ const row: Record = {};
+ rawHeaders.forEach((header, i) => {
+ const field = columnMappings[header];
+ if (field && cells[i] !== undefined && cells[i] !== "") {
+ row[field] = cells[i];
+ }
+ });
+ return Object.keys(row).length > 0 ? [row] : [];
+ });
+}
+
export function UnivApplyInfosPageContent() {
const homeUniversitySelectId = useId();
const termSelectId = useId();
@@ -47,6 +73,7 @@ export function UnivApplyInfosPageContent() {
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"],
@@ -67,6 +94,7 @@ export function UnivApplyInfosPageContent() {
const importMutation = useMutation({
mutationFn: adminApi.importUnivApplyInfos,
onSuccess: (data) => {
+ setShowPreviewModal(false);
setImportResult(data);
if (data.failedRows.length === 0) {
toast.success(`${data.successCount}건 모두 추가됐습니다.`);
@@ -114,10 +142,14 @@ export function UnivApplyInfosPageContent() {
toast.error("먼저 [파싱] 버튼을 눌러 컬럼을 확인해주세요.");
return;
}
+ setShowPreviewModal(true);
+ };
+
+ const handleConfirmImport = () => {
const processedMarkdown = preprocessMarkdownCountryCodes(markdown.trim(), columnMappings);
importMutation.mutate({
- homeUniversityId: univId,
- termId: term,
+ homeUniversityId: Number(homeUniversityId),
+ termId: Number(termId),
markdown: processedMarkdown,
columnMappings,
});
@@ -127,6 +159,18 @@ export function UnivApplyInfosPageContent() {
const terms = termsQuery.data ?? [];
const fields = fieldsQuery.data;
+ const mappedFieldSet = new Set(Object.values(columnMappings).filter(Boolean));
+ const previewColumns = [
+ ...UNIV_APPLY_INFO_FIELDS.filter((f) => mappedFieldSet.has(f.field)).map((f) => ({
+ field: f.field,
+ label: f.label,
+ })),
+ ...[...mappedFieldSet]
+ .filter((f) => !UNIV_APPLY_INFO_FIELDS.some((sf) => sf.field === f))
+ .map((f) => ({ field: f, label: f })),
+ ];
+ const previewRows = showPreviewModal ? parsePreviewRows(markdown.trim(), columnMappings) : [];
+
return (
0 && (
-
+
)}
@@ -309,6 +351,81 @@ export function UnivApplyInfosPageContent() {
)}
)}
+
+ {/* 임포트 미리보기 모달 */}
+ {showPreviewModal && (
+
+
+ )}
);
}
From 6b0965c60960f9e0b3b4f682e41ba89cbd8b51b8 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 01:17:58 +0900
Subject: [PATCH 13/33] =?UTF-8?q?=E2=9C=A8=20=EC=9E=84=ED=8F=AC=ED=8A=B8?=
=?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=ED=95=84=EC=88=98=20?=
=?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=ED=95=AD=EC=83=81=20=ED=91=9C=EC=8B=9C=20?=
=?UTF-8?q?=EB=B0=8F=20=EC=85=80=20=EA=B0=92=20=EB=A7=90=EC=A4=84=EC=9E=84?=
=?UTF-8?q?=20=EC=B2=98=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 45 ++++++++++++++-----
.../univ-apply-infos/univApplyInfoFields.ts | 37 ++++++++-------
2 files changed, 56 insertions(+), 26 deletions(-)
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 96793d98..006ad5d4 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -161,13 +161,24 @@ export function UnivApplyInfosPageContent() {
const mappedFieldSet = new Set(Object.values(columnMappings).filter(Boolean));
const previewColumns = [
- ...UNIV_APPLY_INFO_FIELDS.filter((f) => mappedFieldSet.has(f.field)).map((f) => ({
+ // 필수 필드: 매핑 여부와 관계없이 항상 표시
+ ...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 })),
+ .map((f) => ({ field: f, label: f, required: false, mapped: true })),
];
const previewRows = showPreviewModal ? parsePreviewRows(markdown.trim(), columnMappings) : [];
@@ -371,7 +382,12 @@ export function UnivApplyInfosPageContent() {
임포트 미리보기
- 총 {previewRows.length}개 대학 · {previewColumns.length}개 필드 매핑됨
+ 총 {previewRows.length}개 대학 · {previewColumns.filter((c) => c.mapped).length}개 필드 매핑됨
+ {previewColumns.filter((c) => c.required && !c.mapped).length > 0 && (
+
+ · 필수 {previewColumns.filter((c) => c.required && !c.mapped).length}개 미매핑
+
+ )}
(
- {col.label}
+ {col.mapped ? (
+ {col.label}
+ ) : (
+ {col.label} *
+ )}
|
))}
@@ -403,11 +423,16 @@ export function UnivApplyInfosPageContent() {
{previewRows.map((row, i) => (
| {i + 1} |
- {previewColumns.map((col) => (
-
- {row[col.field] ?? "—"}
- |
- ))}
+ {previewColumns.map((col) => {
+ const value = row[col.field];
+ return (
+
+
+ {value ?? "—"}
+
+ |
+ );
+ })}
))}
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
index 6e48de95..b0972bab 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
@@ -1,20 +1,25 @@
export const UNIV_APPLY_INFO_FIELDS = [
- { field: "universityKoreanName", label: "대학 한국어명", aliases: ["대학명", "학교명", "대학교명"] },
- { field: "universityEnglishName", label: "대학 영어명", aliases: ["영문명", "영어명"] },
- { field: "universityFormatName", label: "대학 표기명", aliases: ["표기명"] },
- { field: "universityCountryCode", label: "국가 코드", aliases: ["국가코드", "국가"] },
- { field: "studentCapacity", label: "모집 인원", aliases: ["인원", "모집인원", "정원", "모집정원"] },
- { field: "tuitionFeeType", label: "등록금 유형", aliases: ["등록금유형", "수업료유형"] },
- { field: "semesterAvailableForDispatch", label: "파견 가능 학기", aliases: ["파견가능학기", "파견학기"] },
- { field: "semesterRequirement", label: "학기 요건", aliases: ["학기요건", "재학학기"] },
- { field: "detailsForLanguage", label: "어학 사항", aliases: ["어학사항", "어학요건상세"] },
- { field: "gpaRequirement", label: "성적 요건", aliases: ["성적요건", "학점요건", "최소학점"] },
- { field: "gpaRequirementCriteria", label: "학점 기준", aliases: ["학점기준", "성적기준"] },
- { field: "detailsForApply", label: "지원 사항", aliases: ["지원사항", "지원안내"] },
- { field: "detailsForMajor", label: "전공 사항", aliases: ["전공사항", "전공안내"] },
- { field: "detailsForAccommodation", label: "숙소 사항", aliases: ["숙소사항", "기숙사안내"] },
- { field: "detailsForEnglishCourse", label: "영어 강좌", aliases: ["영어강좌", "영어강의"] },
- { field: "details", label: "기타 사항", aliases: ["기타사항", "비고"] },
+ { field: "universityKoreanName", label: "대학명 (국문)", required: true, aliases: ["대학명", "학교명", "대학교명"] },
+ { field: "universityEnglishName", label: "대학명 (영문)", required: true, aliases: ["영문명", "영어명"] },
+ { field: "universityFormatName", label: "대학 표기명", required: false, aliases: ["표기명"] },
+ { field: "universityCountryCode", label: "국가 코드", required: true, aliases: ["국가코드", "국가"] },
+ { field: "studentCapacity", label: "선발 인원", required: true, aliases: ["인원", "모집인원", "정원", "모집정원"] },
+ {
+ field: "semesterAvailableForDispatch",
+ label: "파견 가능 학기",
+ required: true,
+ aliases: ["파견가능학기", "파견학기"],
+ },
+ { field: "semesterRequirement", label: "최저 이수학기", required: true, aliases: ["학기요건", "재학학기"] },
+ { field: "gpaRequirement", label: "최저 성적 요건", required: true, aliases: ["성적요건", "학점요건", "최소학점"] },
+ { field: "gpaRequirementCriteria", label: "성적 기준", required: true, aliases: ["학점기준", "성적기준"] },
+ { field: "detailsForLanguage", label: "어학 요건", required: true, aliases: ["어학사항", "어학요건상세"] },
+ { field: "detailsForAccommodation", label: "기숙사", required: true, aliases: ["숙소사항", "기숙사안내"] },
+ { field: "tuitionFeeType", label: "등록금 유형", required: false, aliases: ["등록금유형", "수업료유형"] },
+ { field: "detailsForApply", label: "지원 사항", required: false, aliases: ["지원사항", "지원안내"] },
+ { field: "detailsForMajor", label: "지원 전공", required: false, aliases: ["전공사항", "전공안내"] },
+ { field: "detailsForEnglishCourse", label: "영어 강좌", required: false, aliases: ["영어강좌", "영어강의"] },
+ { field: "details", label: "비고", required: false, aliases: ["기타사항", "비고"] },
] as const;
export type UnivApplyInfoFieldName = (typeof UNIV_APPLY_INFO_FIELDS)[number]["field"];
From 8955de1ced9d0f548d9c0322616029ae34013fe3 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 10:23:58 +0900
Subject: [PATCH 14/33] =?UTF-8?q?fix:=20=EC=A7=80=EC=9B=90=20=EB=8C=80?=
=?UTF-8?q?=ED=95=99=20=EC=BB=AC=EB=9F=BC=20=EB=A7=A4=ED=95=91=20=EA=B7=9C?=
=?UTF-8?q?=EC=B9=99=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 74 ++++++++-----------
.../univApplyInfoFields.test.ts | 16 ++++
.../univ-apply-infos/univApplyInfoFields.ts | 6 --
3 files changed, 48 insertions(+), 48 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.test.ts
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 006ad5d4..8869fada 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -168,13 +168,6 @@ export function UnivApplyInfosPageContent() {
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))
@@ -264,9 +257,6 @@ export function UnivApplyInfosPageContent() {
{parsedHeaders.length > 0 && (
③ 컬럼 매핑
-
- 마크다운 헤더를 시스템 필드에 매핑합니다. 비워두면 extraInfo에 저장됩니다.
-
@@ -276,31 +266,38 @@ export function UnivApplyInfosPageContent() {
- {parsedHeaders.map((header) => (
-
- {header}
-
- {fields?.languageTestTypes.includes(columnMappings[header] ?? "") ? (
-
- 언어 시험 타입: {columnMappings[header]}
-
- ) : (
-
+ )}
+
+
+ );
+ })}
@@ -381,14 +378,7 @@ export function UnivApplyInfosPageContent() {
임포트 미리보기
-
- 총 {previewRows.length}개 대학 · {previewColumns.filter((c) => c.mapped).length}개 필드 매핑됨
- {previewColumns.filter((c) => c.required && !c.mapped).length > 0 && (
-
- · 필수 {previewColumns.filter((c) => c.required && !c.mapped).length}개 미매핑
-
- )}
-
+
총 {previewRows.length}개 대학
{
+ it("contains only required system fields", () => {
+ expect(UNIV_APPLY_INFO_FIELDS.every((field) => field.required)).toBe(true);
+ });
+
+ it("does not match optional headers that should be stored in extraInfo", () => {
+ const extraInfoHeaders = ["등록금유형", "지원사항", "전공사항", "영어강좌", "비고", "표기명"];
+
+ for (const header of extraInfoHeaders) {
+ expect(findFieldByHeader(header)).toBeUndefined();
+ }
+ });
+});
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
index b0972bab..04ed966b 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
@@ -1,7 +1,6 @@
export const UNIV_APPLY_INFO_FIELDS = [
{ field: "universityKoreanName", label: "대학명 (국문)", required: true, aliases: ["대학명", "학교명", "대학교명"] },
{ field: "universityEnglishName", label: "대학명 (영문)", required: true, aliases: ["영문명", "영어명"] },
- { field: "universityFormatName", label: "대학 표기명", required: false, aliases: ["표기명"] },
{ field: "universityCountryCode", label: "국가 코드", required: true, aliases: ["국가코드", "국가"] },
{ field: "studentCapacity", label: "선발 인원", required: true, aliases: ["인원", "모집인원", "정원", "모집정원"] },
{
@@ -15,11 +14,6 @@ export const UNIV_APPLY_INFO_FIELDS = [
{ field: "gpaRequirementCriteria", label: "성적 기준", required: true, aliases: ["학점기준", "성적기준"] },
{ field: "detailsForLanguage", label: "어학 요건", required: true, aliases: ["어학사항", "어학요건상세"] },
{ field: "detailsForAccommodation", label: "기숙사", required: true, aliases: ["숙소사항", "기숙사안내"] },
- { field: "tuitionFeeType", label: "등록금 유형", required: false, aliases: ["등록금유형", "수업료유형"] },
- { field: "detailsForApply", label: "지원 사항", required: false, aliases: ["지원사항", "지원안내"] },
- { field: "detailsForMajor", label: "지원 전공", required: false, aliases: ["전공사항", "전공안내"] },
- { field: "detailsForEnglishCourse", label: "영어 강좌", required: false, aliases: ["영어강좌", "영어강의"] },
- { field: "details", label: "비고", required: false, aliases: ["기타사항", "비고"] },
] as const;
export type UnivApplyInfoFieldName = (typeof UNIV_APPLY_INFO_FIELDS)[number]["field"];
From cc9bf36fb9b66a95193fb7e8617f9459e98359f7 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 13:54:05 +0900
Subject: [PATCH 15/33] =?UTF-8?q?fix:=20=EC=A7=80=EC=9B=90=20=EC=A0=95?=
=?UTF-8?q?=EB=B3=B4=20=EC=9E=84=ED=8F=AC=ED=8A=B8=20=EC=98=A4=EB=A5=98=20?=
=?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.test.tsx | 94 ++++++++++++
.../UnivApplyInfosPageContent.tsx | 134 +++++++++++-------
.../univ-apply-infos/countryCodeAliases.ts | 19 ++-
.../univApplyInfoFields.test.ts | 7 +
.../univ-apply-infos/univApplyInfoFields.ts | 12 +-
.../univApplyInfoPreview.test.ts | 81 +++++++++++
.../univ-apply-infos/univApplyInfoPreview.ts | 79 +++++++++++
apps/admin/src/lib/api/admin.ts | 16 ++-
8 files changed, 382 insertions(+), 60 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
diff --git a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
new file mode 100644
index 00000000..0b567e67
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
@@ -0,0 +1,94 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { adminApi } from "@/lib/api/admin";
+import { UnivApplyInfosPageContent } from "./UnivApplyInfosPageContent";
+
+vi.mock("@/components/layout/AdminLayout", () => ({
+ AdminLayout: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock("@/lib/api/admin", () => ({
+ adminApi: {
+ getHomeUniversities: vi.fn(),
+ getTerms: vi.fn(),
+ getUnivApplyInfoFields: vi.fn(),
+ importUnivApplyInfos: vi.fn(),
+ },
+}));
+
+const mockedAdminApi = vi.mocked(adminApi);
+
+function renderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return render(
+
+
+ ,
+ );
+}
+
+describe("UnivApplyInfosPageContent", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedAdminApi.getHomeUniversities.mockResolvedValue([{ id: 1, name: "본교", maxChoiceCount: 3 }]);
+ mockedAdminApi.getTerms.mockResolvedValue([{ id: 1, label: "2026-1", isCurrent: true }]);
+ mockedAdminApi.getUnivApplyInfoFields.mockResolvedValue({ languageTestTypes: [] });
+ mockedAdminApi.importUnivApplyInfos.mockResolvedValue({
+ successCount: 0,
+ createdUniversities: [],
+ failedRows: [
+ {
+ rowNumber: 1,
+ reason: "2개 컬럼에 문제가 있습니다.",
+ errors: [
+ {
+ header: "국가 코드",
+ field: "universityCountryCode",
+ value: "Belgium",
+ code: "NOT_FOUND",
+ message: "국가를 찾을 수 없습니다.",
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it("renders server cell errors inline in the import preview modal", async () => {
+ renderPage();
+
+ await screen.findByText("본교");
+ await screen.findByText("2026-1");
+ fireEvent.change(screen.getByLabelText("협정 대학"), { target: { value: "1" } });
+ fireEvent.change(screen.getByLabelText("학기"), { target: { value: "1" } });
+ fireEvent.change(screen.getByRole("textbox"), {
+ target: {
+ value: "| 대학명 (국문) | 국가 코드 |\n|--------------|-----------|\n| | Belgium |",
+ },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "파싱" }));
+ await screen.findByText("③ 컬럼 매핑");
+ fireEvent.click(screen.getByRole("button", { name: "지원 대학 추가" }));
+ fireEvent.click(await screen.findByRole("button", { name: "추가" }));
+
+ await waitFor(() => {
+ expect(within(screen.getByRole("dialog")).getByText("국가를 찾을 수 없습니다.")).toBeTruthy();
+ });
+ });
+});
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 8869fada..b84ebe3f 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -1,25 +1,28 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
-import { type FormEvent, useId, useState } from "react";
+import { type FormEvent, useEffect, useId, 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 { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
+import { isValidCountryCode, preprocessMarkdownCountryCodes } from "./countryCodeAliases";
import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
+import {
+ buildFailedCellMessages,
+ buildPreviewRows,
+ getPreviewCellError,
+ parseMarkdownRow,
+} from "./univApplyInfoPreview";
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);
+ return parseMarkdownRow(lines[0]).filter((h) => h.length > 0);
}
function buildAutoMappings(headers: string[], languageTestTypes: string[]): Record {
@@ -37,32 +40,6 @@ function buildAutoMappings(headers: string[], languageTestTypes: string[]): Reco
return mappings;
}
-function parsePreviewRows(markdown: string, columnMappings: Record): Array> {
- const processed = preprocessMarkdownCountryCodes(markdown, columnMappings);
- const lines = processed.trim().split("\n");
- if (lines.length < 3) return [];
-
- const rawHeaders = lines[0]
- .split("|")
- .slice(1, -1)
- .map((h) => h.trim());
-
- return lines.slice(2).flatMap((line) => {
- const cells = line
- .split("|")
- .slice(1, -1)
- .map((c) => c.trim());
- const row: Record = {};
- rawHeaders.forEach((header, i) => {
- const field = columnMappings[header];
- if (field && cells[i] !== undefined && cells[i] !== "") {
- row[field] = cells[i];
- }
- });
- return Object.keys(row).length > 0 ? [row] : [];
- });
-}
-
export function UnivApplyInfosPageContent() {
const homeUniversitySelectId = useId();
const termSelectId = useId();
@@ -94,7 +71,7 @@ export function UnivApplyInfosPageContent() {
const importMutation = useMutation({
mutationFn: adminApi.importUnivApplyInfos,
onSuccess: (data) => {
- setShowPreviewModal(false);
+ setShowPreviewModal(data.failedRows.length > 0);
setImportResult(data);
if (data.failedRows.length === 0) {
toast.success(`${data.successCount}건 모두 추가됐습니다.`);
@@ -105,6 +82,18 @@ export function UnivApplyInfosPageContent() {
onError: () => toast.error("지원 대학 추가에 실패했습니다."),
});
+ useEffect(() => {
+ const firstFailedRowNumber = importResult?.failedRows[0]?.rowNumber;
+ if (!showPreviewModal || !firstFailedRowNumber) return;
+
+ requestAnimationFrame(() => {
+ const rowElement = document.querySelector(`[data-preview-row-number="${firstFailedRowNumber}"]`);
+ if (typeof rowElement?.scrollIntoView === "function") {
+ rowElement.scrollIntoView({ block: "center" });
+ }
+ });
+ }, [importResult, showPreviewModal]);
+
const handleMarkdownChange = (e: React.ChangeEvent) => {
setMarkdown(e.target.value);
if (parsedHeaders.length > 0) {
@@ -173,7 +162,20 @@ export function UnivApplyInfosPageContent() {
.filter((f) => !UNIV_APPLY_INFO_FIELDS.some((sf) => sf.field === f))
.map((f) => ({ field: f, label: f, required: false, mapped: true })),
];
- const previewRows = showPreviewModal ? parsePreviewRows(markdown.trim(), columnMappings) : [];
+ const previewRows = showPreviewModal ? buildPreviewRows(markdown.trim(), columnMappings) : [];
+ const failedRowNumbers = new Set(importResult?.failedRows.map((row) => row.rowNumber) ?? []);
+ const failedCells = importResult?.failedRows.flatMap((row) => {
+ if (row.errors.length === 0) {
+ return [{ rowNumber: row.rowNumber, header: "-", value: "-", message: row.reason }];
+ }
+ return row.errors.map((error) => ({
+ rowNumber: row.rowNumber,
+ header: error.header || error.field || "-",
+ value: error.value || "-",
+ message: error.message || row.reason,
+ }));
+ });
+ const failedCellMessages = buildFailedCellMessages(importResult);
return (
행 번호
+ 컬럼
+ 입력값
실패 이유
- {importResult.failedRows.map((row) => (
-
- {row.rowNumber}
- {row.reason}
+ {failedCells?.map((error, index) => (
+
+ {error.rowNumber}
+ {error.header}
+
+
+ {error.value}
+
+
+ {error.message}
))}
@@ -378,7 +388,12 @@ export function UnivApplyInfosPageContent() {
임포트 미리보기
-
총 {previewRows.length}개 대학
+
+ 총 {previewRows.length}개 대학
+ {importResult && importResult.failedRows.length > 0 && (
+ 실패 {importResult.failedRows.length}건
+ )}
+
- {col.mapped ? (
- {col.label}
- ) : (
- {col.label} *
- )}
+ {col.label}
))}
- {previewRows.map((row, i) => (
-
- | {i + 1} |
+ {previewRows.map((row) => (
+
+ | {row.rowNumber} |
{previewColumns.map((col) => {
- const value = row[col.field];
+ const cell = row.cellsByField[col.field];
+ const cellError = getPreviewCellError(failedCellMessages, row, col.field);
+ const hasCountryCodeWarning =
+ col.field === "universityCountryCode" &&
+ cell?.value !== undefined &&
+ !isValidCountryCode(cell.value);
return (
-
-
- {value ?? "—"}
+ |
+
+ {cell?.value ?? "—"}
+ {hasCountryCodeWarning && !cellError && " *"}
+ {cellError && {cellError} }
|
);
})}
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
index cc47e5cd..f57669c1 100644
--- a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
@@ -81,14 +81,22 @@ export function resolveCountryCode(value: string): string {
return COUNTRY_CODE_BY_NAME[value.trim()] ?? value;
}
+export function isValidCountryCode(resolvedValue: string): boolean {
+ return /^[A-Z]{2}$/.test(resolvedValue);
+}
+
+function parseMarkdownRow(line: string): string[] {
+ let stripped = line.trim();
+ if (stripped.startsWith("|")) stripped = stripped.slice(1);
+ if (stripped.endsWith("|")) stripped = stripped.slice(0, -1);
+ return stripped.split("|").map((cell) => cell.trim());
+}
+
export function preprocessMarkdownCountryCodes(markdown: string, columnMappings: Record): string {
const lines = markdown.trim().split("\n");
if (lines.length < 3) return markdown;
- const headers = lines[0]
- .split("|")
- .map((h) => h.trim())
- .filter((h) => h.length > 0);
+ const headers = parseMarkdownRow(lines[0]);
const countryCodeIndices = headers.reduce((acc, header, i) => {
if (columnMappings[header] === "universityCountryCode") acc.push(i);
@@ -99,9 +107,10 @@ export function preprocessMarkdownCountryCodes(markdown: string, columnMappings:
const processedLines = lines.map((line, lineIndex) => {
if (lineIndex === 0 || lineIndex === 1) return line;
+ const hasLeadingPipe = line.trim().startsWith("|");
const cells = line.split("|");
countryCodeIndices.forEach((colIndex) => {
- const cellIndex = colIndex + 1;
+ const cellIndex = hasLeadingPipe ? colIndex + 1 : colIndex;
if (cells[cellIndex] !== undefined) {
cells[cellIndex] = ` ${resolveCountryCode(cells[cellIndex].trim())} `;
}
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.test.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.test.ts
index 68af22cb..3ba02564 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.test.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.test.ts
@@ -6,6 +6,13 @@ describe("univApplyInfoFields", () => {
expect(UNIV_APPLY_INFO_FIELDS.every((field) => field.required)).toBe(true);
});
+ it("matches required field labels and whitespace variants", () => {
+ expect(findFieldByHeader("국가 코드")).toBe("universityCountryCode");
+ expect(findFieldByHeader("국가코드")).toBe("universityCountryCode");
+ expect(findFieldByHeader("선발 인원")).toBe("studentCapacity");
+ expect(findFieldByHeader("파견 가능 학기")).toBe("semesterAvailableForDispatch");
+ });
+
it("does not match optional headers that should be stored in extraInfo", () => {
const extraInfoHeaders = ["등록금유형", "지원사항", "전공사항", "영어강좌", "비고", "표기명"];
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
index 04ed966b..6e142430 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
@@ -18,9 +18,15 @@ export const UNIV_APPLY_INFO_FIELDS = [
export type UnivApplyInfoFieldName = (typeof UNIV_APPLY_INFO_FIELDS)[number]["field"];
+function normalizeHeader(header: string): string {
+ return header.replace(/\s+/g, "").trim();
+}
+
export function findFieldByHeader(header: string): UnivApplyInfoFieldName | undefined {
- const matched = UNIV_APPLY_INFO_FIELDS.find(
- (f) => f.field === header || (f.aliases as readonly string[]).includes(header),
- );
+ const normalizedHeader = normalizeHeader(header);
+ const matched = UNIV_APPLY_INFO_FIELDS.find((f) => {
+ const candidates = [f.field, f.label, ...f.aliases];
+ return candidates.some((candidate) => candidate === header || normalizeHeader(candidate) === normalizedHeader);
+ });
return matched?.field;
}
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts
new file mode 100644
index 00000000..a92dd601
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { buildFailedCellMessages, buildPreviewRows, getPreviewCellError } from "./univApplyInfoPreview";
+
+describe("univApplyInfoPreview", () => {
+ it("matches server cell errors to preview cells by row number and original header", () => {
+ const markdown = `
+| 대학명 | 국가코드 |
+|--------|----------|
+| | Belgium |
+`;
+ const columnMappings = {
+ 대학명: "universityKoreanName",
+ 국가코드: "universityCountryCode",
+ };
+ const rows = buildPreviewRows(markdown, columnMappings);
+ const failedCellMessages = buildFailedCellMessages({
+ successCount: 0,
+ createdUniversities: [],
+ failedRows: [
+ {
+ rowNumber: 1,
+ reason: "2개 컬럼에 문제가 있습니다.",
+ errors: [
+ {
+ header: "대학명",
+ field: "universityKoreanName",
+ value: null,
+ code: "REQUIRED",
+ message: "대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다",
+ },
+ {
+ header: "국가코드",
+ field: "universityCountryCode",
+ value: "Belgium",
+ code: "NOT_FOUND",
+ message: "국가를 찾을 수 없습니다.",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(rows[0]?.cellsByField.universityCountryCode?.value).toBe("Belgium");
+ expect(getPreviewCellError(failedCellMessages, rows[0], "universityCountryCode")).toBe("국가를 찾을 수 없습니다.");
+ });
+
+ it("matches server cell errors when the original header is a required field label", () => {
+ const markdown = `
+| 대학명 (국문) | 국가 코드 |
+|--------------|-----------|
+| | Belgium |
+`;
+ const columnMappings = {
+ "대학명 (국문)": "universityKoreanName",
+ "국가 코드": "universityCountryCode",
+ };
+ const rows = buildPreviewRows(markdown, columnMappings);
+ const failedCellMessages = buildFailedCellMessages({
+ successCount: 0,
+ createdUniversities: [],
+ failedRows: [
+ {
+ rowNumber: 1,
+ reason: "2개 컬럼에 문제가 있습니다.",
+ errors: [
+ {
+ header: "국가 코드",
+ field: "universityCountryCode",
+ value: "Belgium",
+ code: "NOT_FOUND",
+ message: "국가를 찾을 수 없습니다.",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(rows[0]?.cellsByField.universityCountryCode?.value).toBe("Belgium");
+ expect(getPreviewCellError(failedCellMessages, rows[0], "universityCountryCode")).toBe("국가를 찾을 수 없습니다.");
+ });
+});
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
new file mode 100644
index 00000000..cf9da5fa
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
@@ -0,0 +1,79 @@
+import type { UnivApplyInfoImportResponse } from "@/lib/api/admin";
+import { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
+
+export interface PreviewCell {
+ header: string;
+ field: string;
+ value: string;
+}
+
+export interface PreviewRow {
+ rowNumber: number;
+ cellsByField: Record;
+}
+
+export function parseMarkdownRow(line: string): string[] {
+ let stripped = line.trim();
+ if (stripped.startsWith("|")) stripped = stripped.slice(1);
+ if (stripped.endsWith("|")) stripped = stripped.slice(0, -1);
+ return stripped.split("|").map((cell) => cell.trim());
+}
+
+export function buildPreviewRows(markdown: string, columnMappings: Record): PreviewRow[] {
+ const processed = preprocessMarkdownCountryCodes(markdown, columnMappings);
+ const lines = processed.trim().split("\n");
+ if (lines.length < 3) return [];
+
+ const rawHeaders = parseMarkdownRow(lines[0]);
+
+ return lines.slice(2).map((line, rowIndex) => {
+ const cells = parseMarkdownRow(line);
+ const row: PreviewRow = {
+ rowNumber: rowIndex + 1,
+ cellsByField: {},
+ };
+
+ rawHeaders.forEach((header, i) => {
+ const field = columnMappings[header];
+ if (field && cells[i] !== undefined) {
+ row.cellsByField[field] = {
+ header,
+ field,
+ value: cells[i],
+ };
+ }
+ });
+
+ return row;
+ });
+}
+
+export function buildFailedCellMessages(importResult: UnivApplyInfoImportResponse | null): Map {
+ const failedCellMessages = new Map();
+
+ importResult?.failedRows.forEach((row) => {
+ row.errors.forEach((error) => {
+ const message = error.message || row.reason;
+ if (error.header) {
+ failedCellMessages.set(`${row.rowNumber}:header:${error.header}`, message);
+ }
+ if (error.field) {
+ failedCellMessages.set(`${row.rowNumber}:field:${error.field}`, message);
+ }
+ });
+ });
+
+ return failedCellMessages;
+}
+
+export function getPreviewCellError(
+ failedCellMessages: Map,
+ row: PreviewRow,
+ field: string,
+): string | undefined {
+ const cell = row.cellsByField[field];
+ return (
+ failedCellMessages.get(`${row.rowNumber}:field:${field}`) ??
+ (cell?.header ? failedCellMessages.get(`${row.rowNumber}:header:${cell.header}`) : undefined)
+ );
+}
diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts
index 2f04bb5c..ca5fed84 100644
--- a/apps/admin/src/lib/api/admin.ts
+++ b/apps/admin/src/lib/api/admin.ts
@@ -96,10 +96,24 @@ export interface UnivApplyInfoImportRequest {
export interface UnivApplyInfoImportResponse {
successCount: number;
- failedRows: { rowNumber: number; reason: string }[];
+ failedRows: UnivApplyInfoFailedRow[];
createdUniversities: string[];
}
+export interface UnivApplyInfoFailedRow {
+ rowNumber: number;
+ reason: string;
+ errors: UnivApplyInfoCellError[];
+}
+
+export interface UnivApplyInfoCellError {
+ header: string | null;
+ field: string;
+ value: string | null;
+ code: string;
+ message: string | null;
+}
+
const assignMentorApplicationUniversity = (mentorApplicationId: string | number, universityId: number) =>
axiosInstance
.post(`/admin/mentor-applications/${mentorApplicationId}/assign-university`, { universityId })
From 9f5112dc5c23970b9f0a9d7bf39360555ce9eeca Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 15:02:33 +0900
Subject: [PATCH 16/33] =?UTF-8?q?=E2=9C=A8=20=EC=85=80=20=EB=8B=A8?=
=?UTF-8?q?=EC=9C=84=20=EA=B2=80=EC=A6=9D=20=ED=81=B4=EB=9D=BC=EC=9D=B4?=
=?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 75 +++++---
.../univ-apply-infos/countryCodeAliases.ts | 8 +-
.../univ-apply-infos/univApplyInfoPreview.ts | 5 +-
.../univApplyInfoValidation.test.ts | 164 ++++++++++++++++++
.../univApplyInfoValidation.ts | 118 +++++++++++++
5 files changed, 337 insertions(+), 33 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
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 b84ebe3f..14b22004 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -263,43 +263,57 @@ export function UnivApplyInfosPageContent() {
- 마크다운 헤더
시스템 필드
+ 마크다운 컬럼
- {parsedHeaders.map((header) => {
- const mappedValue = columnMappings[header];
- const isLanguageTestType = fields?.languageTestTypes.includes(mappedValue ?? "") ?? false;
-
+ {UNIV_APPLY_INFO_FIELDS.map((f) => {
+ const mappedHeader = Object.entries(columnMappings).find(([, v]) => v === f.field)?.[0] ?? "";
+ const nonLangHeaders = parsedHeaders.filter(
+ (h) => !(fields?.languageTestTypes.includes(h) ?? false),
+ );
return (
-
- {header}
+
+ {f.label}
- {isLanguageTestType ? (
-
- 언어 시험 타입: {mappedValue}
-
- ) : (
-
);
})}
+ {parsedHeaders
+ .filter((h) => fields?.languageTestTypes.includes(h) ?? false)
+ .map((header) => (
+
+ 언어 시험 타입
+
+
+ {header}
+
+
+
+ ))}
@@ -437,6 +451,11 @@ export function UnivApplyInfosPageContent() {
col.field === "universityCountryCode" &&
cell?.value !== undefined &&
!isValidCountryCode(cell.value);
+ const hasCapacityWarning =
+ col.field === "studentCapacity" &&
+ cell?.value !== undefined &&
+ !/^\d+$/.test(cell.value.trim());
+ const hasInlineWarning = (hasCountryCodeWarning || hasCapacityWarning) && !cellError;
return (
{cell?.value ?? "—"}
- {hasCountryCodeWarning && !cellError && " *"}
+ {hasInlineWarning && " *"}
{cellError && {cellError} }
|
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
index f57669c1..ca911161 100644
--- a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
@@ -105,17 +105,19 @@ export function preprocessMarkdownCountryCodes(markdown: string, columnMappings:
if (countryCodeIndices.length === 0) return markdown;
+ const ESCAPED_PIPE = "\x00";
const processedLines = lines.map((line, lineIndex) => {
if (lineIndex === 0 || lineIndex === 1) return line;
const hasLeadingPipe = line.trim().startsWith("|");
- const cells = line.split("|");
+ const cells = line.replace(/\\\|/g, ESCAPED_PIPE).split("|");
countryCodeIndices.forEach((colIndex) => {
const cellIndex = hasLeadingPipe ? colIndex + 1 : colIndex;
if (cells[cellIndex] !== undefined) {
- cells[cellIndex] = ` ${resolveCountryCode(cells[cellIndex].trim())} `;
+ const cellValue = cells[cellIndex].replaceAll(ESCAPED_PIPE, "|").trim();
+ cells[cellIndex] = ` ${resolveCountryCode(cellValue)} `;
}
});
- return cells.join("|");
+ return cells.map((c) => c.replaceAll(ESCAPED_PIPE, "\\|")).join("|");
});
return processedLines.join("\n");
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
index cf9da5fa..332d6851 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
@@ -13,10 +13,11 @@ export interface PreviewRow {
}
export function parseMarkdownRow(line: string): string[] {
- let stripped = line.trim();
+ const ESCAPED_PIPE = "\x00";
+ let stripped = line.trim().replace(/\\\|/g, ESCAPED_PIPE);
if (stripped.startsWith("|")) stripped = stripped.slice(1);
if (stripped.endsWith("|")) stripped = stripped.slice(0, -1);
- return stripped.split("|").map((cell) => cell.trim());
+ return stripped.split("|").map((cell) => cell.trim().replaceAll(ESCAPED_PIPE, "|"));
}
export function buildPreviewRows(markdown: string, columnMappings: Record): PreviewRow[] {
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
new file mode 100644
index 00000000..8a6e1186
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest";
+import type { PreviewRow } from "./univApplyInfoPreview";
+import { mergeErrorMaps, validatePreviewRows } from "./univApplyInfoValidation";
+
+function makeRow(rowNumber: number, cells: Record): PreviewRow {
+ return {
+ rowNumber,
+ cellsByField: Object.fromEntries(
+ Object.entries(cells).map(([field, value]) => [field, { header: field, field, value }]),
+ ),
+ };
+}
+
+describe("validatePreviewRows", () => {
+ it("유효한 행에는 에러를 반환하지 않는다", () => {
+ const rows = [
+ makeRow(1, {
+ universityKoreanName: "괌 대학교",
+ studentCapacity: "3",
+ universityCountryCode: "US",
+ tuitionFeeType: "HOME_UNIVERSITY_PAYMENT",
+ semesterAvailableForDispatch: "ONE_SEMESTER",
+ }),
+ ];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("universityKoreanName이 빈 값이면 REQUIRED 에러를 반환한다", () => {
+ const rows = [makeRow(1, { universityKoreanName: "" })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:universityKoreanName")).toBe("대학명은 필수입니다");
+ });
+
+ it("universityKoreanName이 공백만 있어도 REQUIRED 에러를 반환한다", () => {
+ const rows = [makeRow(1, { universityKoreanName: " " })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:universityKoreanName")).toBe("대학명은 필수입니다");
+ });
+
+ it("universityKoreanName이 100자를 초과하면 TOO_LONG 에러를 반환한다", () => {
+ const rows = [makeRow(1, { universityKoreanName: "가".repeat(101) })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:universityKoreanName")).toContain("100자");
+ });
+
+ it("studentCapacity가 정수가 아니면 에러를 반환한다", () => {
+ const rows = [makeRow(1, { studentCapacity: "School of Business" })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:studentCapacity")).toContain("정수여야 합니다");
+ });
+
+ it("studentCapacity가 빈 값이면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { studentCapacity: "" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("studentCapacity가 유효한 정수이면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { studentCapacity: "5" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("tuitionFeeType이 허용되지 않은 값이면 에러를 반환한다", () => {
+ const rows = [makeRow(1, { tuitionFeeType: "INVALID_TYPE" })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:tuitionFeeType")).toContain("HOME_UNIVERSITY_PAYMENT");
+ });
+
+ it("tuitionFeeType이 유효한 값이면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { tuitionFeeType: "OVERSEAS_UNIVERSITY_PAYMENT" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("tuitionFeeType이 빈 값이면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { tuitionFeeType: "" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("semesterAvailableForDispatch가 허용되지 않은 값이면 에러를 반환한다", () => {
+ const rows = [makeRow(1, { semesterAvailableForDispatch: "UNKNOWN" })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:semesterAvailableForDispatch")).toContain("ONE_SEMESTER");
+ });
+
+ it("semesterAvailableForDispatch가 유효한 값이면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { semesterAvailableForDispatch: "TWO_SEMESTER" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("universityCountryCode가 2자리 대문자가 아니면 에러를 반환한다", () => {
+ const rows = [makeRow(1, { universityCountryCode: "Belgium" })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:universityCountryCode")).toBe("유효하지 않은 국가 코드입니다");
+ });
+
+ it("universityCountryCode가 유효한 2자리 대문자면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { universityCountryCode: "BE" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("universityCountryCode가 빈 값이면 에러를 반환하지 않는다", () => {
+ const rows = [makeRow(1, { universityCountryCode: "" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("semesterRequirement가 100자를 초과하면 에러를 반환한다", () => {
+ const rows = [makeRow(1, { semesterRequirement: "a".repeat(101) })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:semesterRequirement")).toContain("100자");
+ });
+
+ it("details 계열 필드가 1000자를 초과하면 에러를 반환한다", () => {
+ const rows = [makeRow(1, { detailsForAccommodation: "a".repeat(1001) })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.get("1:field:detailsForAccommodation")).toContain("1000자");
+ });
+
+ it("알 수 없는 필드는 무시한다", () => {
+ const rows = [makeRow(1, { TOEIC: "800" })];
+ expect(validatePreviewRows(rows).size).toBe(0);
+ });
+
+ it("한 행에 복수 에러가 있으면 모두 수집한다", () => {
+ const rows = [
+ makeRow(1, {
+ universityKoreanName: "",
+ studentCapacity: "not-a-number",
+ universityCountryCode: "Belgium",
+ }),
+ ];
+ const errors = validatePreviewRows(rows);
+ expect(errors.size).toBe(3);
+ });
+
+ it("여러 행에 걸쳐 에러를 각각 수집한다", () => {
+ const rows = [makeRow(1, { studentCapacity: "abc" }), makeRow(2, { studentCapacity: "xyz" })];
+ const errors = validatePreviewRows(rows);
+ expect(errors.has("1:field:studentCapacity")).toBe(true);
+ expect(errors.has("2:field:studentCapacity")).toBe(true);
+ });
+});
+
+describe("mergeErrorMaps", () => {
+ it("두 맵을 병합한다", () => {
+ const a = new Map([["1:field:foo", "에러A"]]);
+ const b = new Map([["2:field:bar", "에러B"]]);
+ const merged = mergeErrorMaps(a, b);
+ expect(merged.size).toBe(2);
+ expect(merged.get("1:field:foo")).toBe("에러A");
+ expect(merged.get("2:field:bar")).toBe("에러B");
+ });
+
+ it("같은 키는 나중 맵 값으로 덮어쓴다", () => {
+ const a = new Map([["1:field:foo", "에러A"]]);
+ const b = new Map([["1:field:foo", "에러B"]]);
+ const merged = mergeErrorMaps(a, b);
+ expect(merged.get("1:field:foo")).toBe("에러B");
+ });
+
+ it("빈 맵을 병합하면 다른 맵과 동일하다", () => {
+ const a = new Map([["1:field:foo", "에러A"]]);
+ const merged = mergeErrorMaps(a, new Map());
+ expect(merged.size).toBe(1);
+ });
+});
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
new file mode 100644
index 00000000..4d9e6a03
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
@@ -0,0 +1,118 @@
+import { isValidCountryCode } from "./countryCodeAliases";
+import type { PreviewRow } from "./univApplyInfoPreview";
+
+const TUITION_FEE_TYPES = ["HOME_UNIVERSITY_PAYMENT", "OVERSEAS_UNIVERSITY_PAYMENT", "MIXED_PAYMENT"] as const;
+
+const SEMESTER_AVAILABLE_FOR_DISPATCH_VALUES = [
+ "ONE_SEMESTER",
+ "TWO_SEMESTER",
+ "FOUR_SEMESTER",
+ "ONE_OR_TWO_SEMESTER",
+ "ONE_YEAR",
+ "IRRELEVANT",
+ "NO_DATA",
+] as const;
+
+type FieldRule =
+ | { type: "required"; message: string }
+ | { type: "maxLength"; max: number }
+ | { type: "integer"; message: string }
+ | { type: "enum"; values: readonly string[]; label: string }
+ | { type: "format"; check: (v: string) => boolean; message: string };
+
+const FIELD_RULES: Record = {
+ universityKoreanName: [
+ { type: "required", message: "대학명은 필수입니다" },
+ { type: "maxLength", max: 100 },
+ ],
+ universityEnglishName: [{ type: "maxLength", max: 100 }],
+ universityFormatName: [{ type: "maxLength", max: 100 }],
+ universityHomepageUrl: [{ type: "maxLength", max: 500 }],
+ universityEnglishCourseUrl: [{ type: "maxLength", max: 500 }],
+ universityAccommodationUrl: [{ type: "maxLength", max: 500 }],
+ universityDetailsForLocal: [{ type: "maxLength", max: 1000 }],
+ studentCapacity: [{ type: "integer", message: "선발 인원은 정수여야 합니다" }],
+ tuitionFeeType: [{ type: "enum", values: TUITION_FEE_TYPES, label: "등록금 유형" }],
+ semesterAvailableForDispatch: [
+ {
+ type: "enum",
+ values: SEMESTER_AVAILABLE_FOR_DISPATCH_VALUES,
+ label: "파견 가능 학기",
+ },
+ ],
+ semesterRequirement: [{ type: "maxLength", max: 100 }],
+ gpaRequirement: [{ type: "maxLength", max: 100 }],
+ gpaRequirementCriteria: [{ type: "maxLength", max: 100 }],
+ detailsForLanguage: [{ type: "maxLength", max: 1000 }],
+ detailsForApply: [{ type: "maxLength", max: 1000 }],
+ detailsForMajor: [{ type: "maxLength", max: 1000 }],
+ detailsForAccommodation: [{ type: "maxLength", max: 1000 }],
+ detailsForEnglishCourse: [{ type: "maxLength", max: 1000 }],
+ details: [{ type: "maxLength", max: 1000 }],
+ universityCountryCode: [
+ {
+ type: "format",
+ check: isValidCountryCode,
+ message: "유효하지 않은 국가 코드입니다",
+ },
+ ],
+};
+
+function validateCell(value: string, rules: FieldRule[]): string | undefined {
+ for (const rule of rules) {
+ const trimmed = value.trim();
+
+ if (rule.type === "required") {
+ if (!trimmed) return rule.message;
+ continue;
+ }
+
+ if (!trimmed) continue;
+
+ if (rule.type === "maxLength") {
+ if (value.length > rule.max) {
+ return `값이 최대 길이(${rule.max}자)를 초과했습니다: ${value.length}자`;
+ }
+ } else if (rule.type === "integer") {
+ if (!/^\d+$/.test(trimmed)) {
+ return `${rule.message}: '${value}'`;
+ }
+ } else if (rule.type === "enum") {
+ if (!rule.values.includes(trimmed)) {
+ return `유효하지 않은 ${rule.label}입니다. 가능한 값: ${rule.values.join(", ")}`;
+ }
+ } else if (rule.type === "format") {
+ if (!rule.check(trimmed)) {
+ return rule.message;
+ }
+ }
+ }
+ return undefined;
+}
+
+export function validatePreviewRows(rows: PreviewRow[]): Map {
+ const errors = new Map();
+
+ for (const row of rows) {
+ for (const [field, cell] of Object.entries(row.cellsByField)) {
+ const rules = FIELD_RULES[field];
+ if (!rules) continue;
+ const message = validateCell(cell.value, rules);
+ if (message) {
+ errors.set(`${row.rowNumber}:field:${field}`, message);
+ }
+ }
+ }
+
+ return errors;
+}
+
+export function mergeErrorMaps(...maps: Map[]): Map {
+ const merged = new Map();
+ for (const map of maps) {
+ for (const [key, value] of map) {
+ merged.set(key, value);
+ }
+ }
+ return merged;
+}
From 24b9bf12abe9b6cd04dcdb3e05e6ae6e1624e80b Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 15:07:11 +0900
Subject: [PATCH 17/33] =?UTF-8?q?=F0=9F=90=9B=20maxLength=20=EA=B2=80?=
=?UTF-8?q?=EC=82=AC=EB=A5=BC=20trim=20=EC=9D=B4=ED=9B=84=20=EA=B8=B8?=
=?UTF-8?q?=EC=9D=B4=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/univ-apply-infos/univApplyInfoValidation.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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 4d9e6a03..8da7efdf 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
@@ -70,8 +70,8 @@ function validateCell(value: string, rules: FieldRule[]): string | undefined {
if (!trimmed) continue;
if (rule.type === "maxLength") {
- if (value.length > rule.max) {
- return `값이 최대 길이(${rule.max}자)를 초과했습니다: ${value.length}자`;
+ if (trimmed.length > rule.max) {
+ return `값이 최대 길이(${rule.max}자)를 초과했습니다: ${trimmed.length}자`;
}
} else if (rule.type === "integer") {
if (!/^\d+$/.test(trimmed)) {
From 120a72a01e517b6f4e035b8a63a671d96f36b6aa Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 15:09:31 +0900
Subject: [PATCH 18/33] =?UTF-8?q?=E2=9C=A8=20=EC=9E=84=ED=8F=AC=ED=8A=B8?=
=?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=85=80=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20?=
=?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EB=B0=8F=20=EC=B6=94=EA=B0=80=20=EB=B2=84?=
=?UTF-8?q?=ED=8A=BC=20=ED=95=98=EB=93=9C=20=EB=B8=94=EB=A1=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 35 +++++++++----------
1 file changed, 17 insertions(+), 18 deletions(-)
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 14b22004..4002dc42 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -8,7 +8,7 @@ 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 { isValidCountryCode, preprocessMarkdownCountryCodes } from "./countryCodeAliases";
+import { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
import {
buildFailedCellMessages,
@@ -16,6 +16,7 @@ import {
getPreviewCellError,
parseMarkdownRow,
} from "./univApplyInfoPreview";
+import { mergeErrorMaps, validatePreviewRows } from "./univApplyInfoValidation";
function extractMarkdownHeaders(markdown: string): string[] {
const lines = markdown.trim().split("\n");
@@ -163,6 +164,8 @@ 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 clientErrorRowNumbers = new Set([...clientCellErrors.keys()].map((k) => Number(k.split(":")[0])));
const failedRowNumbers = new Set(importResult?.failedRows.map((row) => row.rowNumber) ?? []);
const failedCells = importResult?.failedRows.flatMap((row) => {
if (row.errors.length === 0) {
@@ -175,7 +178,7 @@ export function UnivApplyInfosPageContent() {
message: error.message || row.reason,
}));
});
- const failedCellMessages = buildFailedCellMessages(importResult);
+ const failedCellMessages = mergeErrorMaps(clientCellErrors, buildFailedCellMessages(importResult));
return (
임포트 미리보기
총 {previewRows.length}개 대학
+ {clientErrorRowNumbers.size > 0 && (
+ 오류 {clientErrorRowNumbers.size}행
+ )}
{importResult && importResult.failedRows.length > 0 && (
실패 {importResult.failedRows.length}건
)}
@@ -440,22 +446,15 @@ export function UnivApplyInfosPageContent() {
key={row.rowNumber}
data-preview-row-number={row.rowNumber}
className={`border-b border-k-50 last:border-0 ${
- failedRowNumbers.has(row.rowNumber) ? "bg-magic-danger/5" : ""
+ failedRowNumbers.has(row.rowNumber) || clientErrorRowNumbers.has(row.rowNumber)
+ ? "bg-magic-danger/5"
+ : ""
}`}
>
{row.rowNumber} |
{previewColumns.map((col) => {
const cell = row.cellsByField[col.field];
const cellError = getPreviewCellError(failedCellMessages, row, col.field);
- const hasCountryCodeWarning =
- col.field === "universityCountryCode" &&
- cell?.value !== undefined &&
- !isValidCountryCode(cell.value);
- const hasCapacityWarning =
- col.field === "studentCapacity" &&
- cell?.value !== undefined &&
- !/^\d+$/.test(cell.value.trim());
- const hasInlineWarning = (hasCountryCodeWarning || hasCapacityWarning) && !cellError;
return (
-
+
{cell?.value ?? "—"}
- {hasInlineWarning && " *"}
{cellError && {cellError} }
|
@@ -485,7 +480,11 @@ export function UnivApplyInfosPageContent() {
setShowPreviewModal(false)}>
취소
-
+ 0}
+ >
{importMutation.isPending ? "추가 중..." : "추가"}
From db4c0bee037275b0797c39dfc3f7f6c0e0225e5d Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 15:12:45 +0900
Subject: [PATCH 19/33] =?UTF-8?q?=F0=9F=8E=A8=20=EC=97=90=EB=9F=AC=20?=
=?UTF-8?q?=EB=A7=B5=20=EB=B3=91=ED=95=A9=20=EC=88=9C=EC=84=9C=20=EB=B0=8F?=
=?UTF-8?q?=20=ED=82=A4=20=ED=8F=AC=EB=A7=B7=20=EC=9D=98=EB=8F=84=20?=
=?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/univ-apply-infos/UnivApplyInfosPageContent.tsx | 2 ++
1 file changed, 2 insertions(+)
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 4002dc42..d9049cab 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -165,6 +165,7 @@ export function UnivApplyInfosPageContent() {
];
const previewRows = showPreviewModal ? buildPreviewRows(markdown.trim(), columnMappings) : [];
const clientCellErrors = validatePreviewRows(previewRows);
+ // key format: "rowNumber:field:fieldName" — rowNumber is always the first segment
const clientErrorRowNumbers = new Set([...clientCellErrors.keys()].map((k) => Number(k.split(":")[0])));
const failedRowNumbers = new Set(importResult?.failedRows.map((row) => row.rowNumber) ?? []);
const failedCells = importResult?.failedRows.flatMap((row) => {
@@ -178,6 +179,7 @@ export function UnivApplyInfosPageContent() {
message: error.message || row.reason,
}));
});
+ // server errors are second so they take priority on key collision (post-import ground truth)
const failedCellMessages = mergeErrorMaps(clientCellErrors, buildFailedCellMessages(importResult));
return (
From ea5e6d7599fa9664284ff7298ca3e8683b6c6f57 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 15:14:31 +0900
Subject: [PATCH 20/33] =?UTF-8?q?=E2=9C=85=20=EC=BB=B4=ED=8F=AC=EB=84=8C?=
=?UTF-8?q?=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=81=B4?=
=?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B2=80=EC=A6=9D=20?=
=?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0?=
=?UTF-8?q?=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.test.tsx | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
index 0b567e67..e2c8b746 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "@/lib/api/admin";
import { UnivApplyInfosPageContent } from "./UnivApplyInfosPageContent";
@@ -69,7 +69,7 @@ describe("UnivApplyInfosPageContent", () => {
});
});
- it("renders server cell errors inline in the import preview modal", async () => {
+ it("클라이언트 검증 오류가 있으면 미리보기 모달에 즉시 표시하고 추가 버튼을 비활성화한다", async () => {
renderPage();
await screen.findByText("본교");
@@ -85,10 +85,15 @@ describe("UnivApplyInfosPageContent", () => {
fireEvent.click(screen.getByRole("button", { name: "파싱" }));
await screen.findByText("③ 컬럼 매핑");
fireEvent.click(screen.getByRole("button", { name: "지원 대학 추가" }));
- fireEvent.click(await screen.findByRole("button", { name: "추가" }));
- await waitFor(() => {
- expect(within(screen.getByRole("dialog")).getByText("국가를 찾을 수 없습니다.")).toBeTruthy();
- });
+ await screen.findByRole("dialog");
+
+ expect(within(screen.getByRole("dialog")).getByText("대학명은 필수입니다")).toBeTruthy();
+ expect(within(screen.getByRole("dialog")).getByText("유효하지 않은 국가 코드입니다")).toBeTruthy();
+
+ const addButton = within(screen.getByRole("dialog")).getByRole("button", { name: "추가" });
+ expect((addButton as HTMLButtonElement).disabled).toBe(true);
+
+ expect(mockedAdminApi.importUnivApplyInfos).not.toHaveBeenCalled();
});
});
From 2c578a16c60e3175b1b2beee4d88f6453f376f22 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 15:26:38 +0900
Subject: [PATCH 21/33] =?UTF-8?q?=F0=9F=94=A5=20=ED=85=8C=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.test.tsx | 99 -----------
.../univApplyInfoValidation.test.ts | 164 ------------------
2 files changed, 263 deletions(-)
delete mode 100644 apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
delete mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts
diff --git a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
deleted file mode 100644
index e2c8b746..00000000
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.test.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, within } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { adminApi } from "@/lib/api/admin";
-import { UnivApplyInfosPageContent } from "./UnivApplyInfosPageContent";
-
-vi.mock("@/components/layout/AdminLayout", () => ({
- AdminLayout: ({ children }: { children: React.ReactNode }) => {children}
,
-}));
-
-vi.mock("sonner", () => ({
- toast: {
- success: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
- },
-}));
-
-vi.mock("@/lib/api/admin", () => ({
- adminApi: {
- getHomeUniversities: vi.fn(),
- getTerms: vi.fn(),
- getUnivApplyInfoFields: vi.fn(),
- importUnivApplyInfos: vi.fn(),
- },
-}));
-
-const mockedAdminApi = vi.mocked(adminApi);
-
-function renderPage() {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
-
- return render(
-
-
- ,
- );
-}
-
-describe("UnivApplyInfosPageContent", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockedAdminApi.getHomeUniversities.mockResolvedValue([{ id: 1, name: "본교", maxChoiceCount: 3 }]);
- mockedAdminApi.getTerms.mockResolvedValue([{ id: 1, label: "2026-1", isCurrent: true }]);
- mockedAdminApi.getUnivApplyInfoFields.mockResolvedValue({ languageTestTypes: [] });
- mockedAdminApi.importUnivApplyInfos.mockResolvedValue({
- successCount: 0,
- createdUniversities: [],
- failedRows: [
- {
- rowNumber: 1,
- reason: "2개 컬럼에 문제가 있습니다.",
- errors: [
- {
- header: "국가 코드",
- field: "universityCountryCode",
- value: "Belgium",
- code: "NOT_FOUND",
- message: "국가를 찾을 수 없습니다.",
- },
- ],
- },
- ],
- });
- });
-
- it("클라이언트 검증 오류가 있으면 미리보기 모달에 즉시 표시하고 추가 버튼을 비활성화한다", async () => {
- renderPage();
-
- await screen.findByText("본교");
- await screen.findByText("2026-1");
- fireEvent.change(screen.getByLabelText("협정 대학"), { target: { value: "1" } });
- fireEvent.change(screen.getByLabelText("학기"), { target: { value: "1" } });
- fireEvent.change(screen.getByRole("textbox"), {
- target: {
- value: "| 대학명 (국문) | 국가 코드 |\n|--------------|-----------|\n| | Belgium |",
- },
- });
-
- fireEvent.click(screen.getByRole("button", { name: "파싱" }));
- await screen.findByText("③ 컬럼 매핑");
- fireEvent.click(screen.getByRole("button", { name: "지원 대학 추가" }));
-
- await screen.findByRole("dialog");
-
- expect(within(screen.getByRole("dialog")).getByText("대학명은 필수입니다")).toBeTruthy();
- expect(within(screen.getByRole("dialog")).getByText("유효하지 않은 국가 코드입니다")).toBeTruthy();
-
- const addButton = within(screen.getByRole("dialog")).getByRole("button", { name: "추가" });
- expect((addButton as HTMLButtonElement).disabled).toBe(true);
-
- expect(mockedAdminApi.importUnivApplyInfos).not.toHaveBeenCalled();
- });
-});
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
deleted file mode 100644
index 8a6e1186..00000000
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.test.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { PreviewRow } from "./univApplyInfoPreview";
-import { mergeErrorMaps, validatePreviewRows } from "./univApplyInfoValidation";
-
-function makeRow(rowNumber: number, cells: Record): PreviewRow {
- return {
- rowNumber,
- cellsByField: Object.fromEntries(
- Object.entries(cells).map(([field, value]) => [field, { header: field, field, value }]),
- ),
- };
-}
-
-describe("validatePreviewRows", () => {
- it("유효한 행에는 에러를 반환하지 않는다", () => {
- const rows = [
- makeRow(1, {
- universityKoreanName: "괌 대학교",
- studentCapacity: "3",
- universityCountryCode: "US",
- tuitionFeeType: "HOME_UNIVERSITY_PAYMENT",
- semesterAvailableForDispatch: "ONE_SEMESTER",
- }),
- ];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("universityKoreanName이 빈 값이면 REQUIRED 에러를 반환한다", () => {
- const rows = [makeRow(1, { universityKoreanName: "" })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:universityKoreanName")).toBe("대학명은 필수입니다");
- });
-
- it("universityKoreanName이 공백만 있어도 REQUIRED 에러를 반환한다", () => {
- const rows = [makeRow(1, { universityKoreanName: " " })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:universityKoreanName")).toBe("대학명은 필수입니다");
- });
-
- it("universityKoreanName이 100자를 초과하면 TOO_LONG 에러를 반환한다", () => {
- const rows = [makeRow(1, { universityKoreanName: "가".repeat(101) })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:universityKoreanName")).toContain("100자");
- });
-
- it("studentCapacity가 정수가 아니면 에러를 반환한다", () => {
- const rows = [makeRow(1, { studentCapacity: "School of Business" })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:studentCapacity")).toContain("정수여야 합니다");
- });
-
- it("studentCapacity가 빈 값이면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { studentCapacity: "" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("studentCapacity가 유효한 정수이면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { studentCapacity: "5" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("tuitionFeeType이 허용되지 않은 값이면 에러를 반환한다", () => {
- const rows = [makeRow(1, { tuitionFeeType: "INVALID_TYPE" })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:tuitionFeeType")).toContain("HOME_UNIVERSITY_PAYMENT");
- });
-
- it("tuitionFeeType이 유효한 값이면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { tuitionFeeType: "OVERSEAS_UNIVERSITY_PAYMENT" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("tuitionFeeType이 빈 값이면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { tuitionFeeType: "" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("semesterAvailableForDispatch가 허용되지 않은 값이면 에러를 반환한다", () => {
- const rows = [makeRow(1, { semesterAvailableForDispatch: "UNKNOWN" })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:semesterAvailableForDispatch")).toContain("ONE_SEMESTER");
- });
-
- it("semesterAvailableForDispatch가 유효한 값이면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { semesterAvailableForDispatch: "TWO_SEMESTER" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("universityCountryCode가 2자리 대문자가 아니면 에러를 반환한다", () => {
- const rows = [makeRow(1, { universityCountryCode: "Belgium" })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:universityCountryCode")).toBe("유효하지 않은 국가 코드입니다");
- });
-
- it("universityCountryCode가 유효한 2자리 대문자면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { universityCountryCode: "BE" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("universityCountryCode가 빈 값이면 에러를 반환하지 않는다", () => {
- const rows = [makeRow(1, { universityCountryCode: "" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("semesterRequirement가 100자를 초과하면 에러를 반환한다", () => {
- const rows = [makeRow(1, { semesterRequirement: "a".repeat(101) })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:semesterRequirement")).toContain("100자");
- });
-
- it("details 계열 필드가 1000자를 초과하면 에러를 반환한다", () => {
- const rows = [makeRow(1, { detailsForAccommodation: "a".repeat(1001) })];
- const errors = validatePreviewRows(rows);
- expect(errors.get("1:field:detailsForAccommodation")).toContain("1000자");
- });
-
- it("알 수 없는 필드는 무시한다", () => {
- const rows = [makeRow(1, { TOEIC: "800" })];
- expect(validatePreviewRows(rows).size).toBe(0);
- });
-
- it("한 행에 복수 에러가 있으면 모두 수집한다", () => {
- const rows = [
- makeRow(1, {
- universityKoreanName: "",
- studentCapacity: "not-a-number",
- universityCountryCode: "Belgium",
- }),
- ];
- const errors = validatePreviewRows(rows);
- expect(errors.size).toBe(3);
- });
-
- it("여러 행에 걸쳐 에러를 각각 수집한다", () => {
- const rows = [makeRow(1, { studentCapacity: "abc" }), makeRow(2, { studentCapacity: "xyz" })];
- const errors = validatePreviewRows(rows);
- expect(errors.has("1:field:studentCapacity")).toBe(true);
- expect(errors.has("2:field:studentCapacity")).toBe(true);
- });
-});
-
-describe("mergeErrorMaps", () => {
- it("두 맵을 병합한다", () => {
- const a = new Map([["1:field:foo", "에러A"]]);
- const b = new Map([["2:field:bar", "에러B"]]);
- const merged = mergeErrorMaps(a, b);
- expect(merged.size).toBe(2);
- expect(merged.get("1:field:foo")).toBe("에러A");
- expect(merged.get("2:field:bar")).toBe("에러B");
- });
-
- it("같은 키는 나중 맵 값으로 덮어쓴다", () => {
- const a = new Map([["1:field:foo", "에러A"]]);
- const b = new Map([["1:field:foo", "에러B"]]);
- const merged = mergeErrorMaps(a, b);
- expect(merged.get("1:field:foo")).toBe("에러B");
- });
-
- it("빈 맵을 병합하면 다른 맵과 동일하다", () => {
- const a = new Map([["1:field:foo", "에러A"]]);
- const merged = mergeErrorMaps(a, new Map());
- expect(merged.size).toBe(1);
- });
-});
From 75a24b7603401aee4b38a43c90f6be8a88f6ef62 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 16:25:04 +0900
Subject: [PATCH 22/33] =?UTF-8?q?=E2=9C=A8=20=EC=BB=AC=EB=9F=BC=20?=
=?UTF-8?q?=EB=A7=A4=ED=95=91=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20?=
=?UTF-8?q?=EB=B0=8F=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EB=B9=84?=
=?UTF-8?q?=ED=95=84=EC=88=98=20=EC=BB=AC=EB=9F=BC=20=ED=91=9C=EC=8B=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 7 +++++++
.../univ-apply-infos/univApplyInfoFields.ts | 15 +++++++++++++--
2 files changed, 20 insertions(+), 2 deletions(-)
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 d9049cab..1bb3b8a8 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -158,6 +158,13 @@ export function UnivApplyInfosPageContent() {
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))
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
index 6e142430..33abffb9 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoFields.ts
@@ -1,7 +1,7 @@
export const UNIV_APPLY_INFO_FIELDS = [
{ field: "universityKoreanName", label: "대학명 (국문)", required: true, aliases: ["대학명", "학교명", "대학교명"] },
{ field: "universityEnglishName", label: "대학명 (영문)", required: true, aliases: ["영문명", "영어명"] },
- { field: "universityCountryCode", label: "국가 코드", required: true, aliases: ["국가코드", "국가"] },
+ { field: "universityCountryCode", label: "국가", required: true, aliases: ["국가코드"] },
{ field: "studentCapacity", label: "선발 인원", required: true, aliases: ["인원", "모집인원", "정원", "모집정원"] },
{
field: "semesterAvailableForDispatch",
@@ -12,8 +12,19 @@ export const UNIV_APPLY_INFO_FIELDS = [
{ field: "semesterRequirement", label: "최저 이수학기", required: true, aliases: ["학기요건", "재학학기"] },
{ field: "gpaRequirement", label: "최저 성적 요건", required: true, aliases: ["성적요건", "학점요건", "최소학점"] },
{ field: "gpaRequirementCriteria", label: "성적 기준", required: true, aliases: ["학점기준", "성적기준"] },
- { field: "detailsForLanguage", label: "어학 요건", required: true, aliases: ["어학사항", "어학요건상세"] },
+ {
+ field: "detailsForLanguage",
+ label: "어학 세부 요건",
+ required: true,
+ aliases: ["어학사항", "어학요건상세", "어학요건"],
+ },
{ field: "detailsForAccommodation", label: "기숙사", required: true, aliases: ["숙소사항", "기숙사안내"] },
+ {
+ field: "universityHomepageUrl",
+ label: "관련 홈페이지",
+ required: false,
+ aliases: ["홈페이지", "대학홈페이지", "관련홈페이지"],
+ },
] as const;
export type UnivApplyInfoFieldName = (typeof UNIV_APPLY_INFO_FIELDS)[number]["field"];
From fefa2ae4157e158f3f0b1d652bf48da240f1dd7f Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 19:48:04 +0900
Subject: [PATCH 23/33] =?UTF-8?q?=E2=9C=A8=20=EA=B5=AD=EA=B0=80=EB=AA=85?=
=?UTF-8?q?=20alias=20=EC=B6=94=EA=B0=80=20(Colombia,=20Czech,=20Estonia,?=
=?UTF-8?q?=20Ireland)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/univ-apply-infos/countryCodeAliases.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
index ca911161..850d0e84 100644
--- a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
@@ -6,15 +6,18 @@ export const COUNTRY_CODE_BY_NAME: Record = {
브루나이: "BN",
브라질: "BR",
캐나다: "CA",
+ 콜롬비아: "CO",
스위스: "CH",
중국: "CN",
체코: "CZ",
+ 에스토니아: "EE",
독일: "DE",
덴마크: "DK",
스페인: "ES",
핀란드: "FI",
프랑스: "FR",
영국: "GB",
+ 아일랜드: "IE",
홍콩: "HK",
헝가리: "HU",
인도네시아: "ID",
@@ -42,10 +45,13 @@ export const COUNTRY_CODE_BY_NAME: Record = {
Brunei: "BN",
Brazil: "BR",
Canada: "CA",
+ Colombia: "CO",
Switzerland: "CH",
China: "CN",
"Czech Republic": "CZ",
Czechia: "CZ",
+ Czech: "CZ",
+ Estonia: "EE",
Germany: "DE",
Denmark: "DK",
Spain: "ES",
@@ -55,6 +61,7 @@ export const COUNTRY_CODE_BY_NAME: Record = {
UK: "GB",
"Hong Kong": "HK",
Hungary: "HU",
+ Ireland: "IE",
Indonesia: "ID",
Israel: "IL",
Italy: "IT",
From fc5d6d11514e0c00f9e486afa1ca1ca9a35805b4 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 20:01:17 +0900
Subject: [PATCH 24/33] =?UTF-8?q?=E2=9C=A8=20=EA=B5=AD=EA=B0=80=EB=AA=85?=
=?UTF-8?q?=20alias=20=EC=B6=94=EA=B0=80=20(Belgium)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/features/univ-apply-infos/countryCodeAliases.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
index 850d0e84..995176b2 100644
--- a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
@@ -3,6 +3,7 @@ export const COUNTRY_CODE_BY_NAME: Record = {
오스트리아: "AT",
호주: "AU",
아제르바이잔: "AZ",
+ 벨기에: "BE",
브루나이: "BN",
브라질: "BR",
캐나다: "CA",
@@ -42,6 +43,7 @@ export const COUNTRY_CODE_BY_NAME: Record = {
Austria: "AT",
Australia: "AU",
Azerbaijan: "AZ",
+ Belgium: "BE",
Brunei: "BN",
Brazil: "BR",
Canada: "CA",
From 06aa95da4a62c90dd7721610e7f3adf78f161fcb Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 20:04:56 +0900
Subject: [PATCH 25/33] =?UTF-8?q?=F0=9F=94=A7=20=EC=98=81=EB=AC=B8=20?=
=?UTF-8?q?=EB=8C=80=ED=95=99=EB=AA=85=20200=EC=9E=90,=20=EA=B8=B0?=
=?UTF-8?q?=EC=88=99=EC=82=AC=C2=B7=EC=96=B4=ED=95=99=20=EC=84=B8=EB=B6=80?=
=?UTF-8?q?=20=EC=9A=94=EA=B1=B4=202000=EC=9E=90=EB=A1=9C=20maxLength=20?=
=?UTF-8?q?=EC=A1=B0=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/univ-apply-infos/univApplyInfoValidation.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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 8da7efdf..4cefd06c 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
@@ -25,7 +25,7 @@ const FIELD_RULES: Record = {
{ type: "required", message: "대학명은 필수입니다" },
{ type: "maxLength", max: 100 },
],
- universityEnglishName: [{ type: "maxLength", max: 100 }],
+ universityEnglishName: [{ type: "maxLength", max: 200 }],
universityFormatName: [{ type: "maxLength", max: 100 }],
universityHomepageUrl: [{ type: "maxLength", max: 500 }],
universityEnglishCourseUrl: [{ type: "maxLength", max: 500 }],
@@ -43,10 +43,10 @@ const FIELD_RULES: Record = {
semesterRequirement: [{ type: "maxLength", max: 100 }],
gpaRequirement: [{ type: "maxLength", max: 100 }],
gpaRequirementCriteria: [{ type: "maxLength", max: 100 }],
- detailsForLanguage: [{ type: "maxLength", max: 1000 }],
+ detailsForLanguage: [{ type: "maxLength", max: 2000 }],
detailsForApply: [{ type: "maxLength", max: 1000 }],
detailsForMajor: [{ type: "maxLength", max: 1000 }],
- detailsForAccommodation: [{ type: "maxLength", max: 1000 }],
+ detailsForAccommodation: [{ type: "maxLength", max: 2000 }],
detailsForEnglishCourse: [{ type: "maxLength", max: 1000 }],
details: [{ type: "maxLength", max: 1000 }],
universityCountryCode: [
From 96fc23efd6ebfc08361618ccfcd7be51000d240b Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 20:19:28 +0900
Subject: [PATCH 26/33] =?UTF-8?q?=F0=9F=94=A7=20=EC=88=98=EA=B0=95?=
=?UTF-8?q?=EA=B3=BC=EB=AA=A9/=EC=A7=80=EC=9B=90=EC=9E=90=EA=B2=A9/?=
=?UTF-8?q?=EB=B9=84=EA=B3=A0=20maxLength=203000=EC=9E=90=EB=A1=9C=20?=
=?UTF-8?q?=EC=A1=B0=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/univ-apply-infos/univApplyInfoValidation.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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 4cefd06c..aeef5b38 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
@@ -44,11 +44,11 @@ const FIELD_RULES: Record = {
gpaRequirement: [{ type: "maxLength", max: 100 }],
gpaRequirementCriteria: [{ type: "maxLength", max: 100 }],
detailsForLanguage: [{ type: "maxLength", max: 2000 }],
- detailsForApply: [{ type: "maxLength", max: 1000 }],
- detailsForMajor: [{ type: "maxLength", max: 1000 }],
+ detailsForApply: [{ type: "maxLength", max: 3000 }],
+ detailsForMajor: [{ type: "maxLength", max: 3000 }],
detailsForAccommodation: [{ type: "maxLength", max: 2000 }],
detailsForEnglishCourse: [{ type: "maxLength", max: 1000 }],
- details: [{ type: "maxLength", max: 1000 }],
+ details: [{ type: "maxLength", max: 3000 }],
universityCountryCode: [
{
type: "format",
From 8cf689fa072f6ca1c25a63a74c978a6e54953ca3 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Wed, 17 Jun 2026 21:00:36 +0900
Subject: [PATCH 27/33] =?UTF-8?q?=F0=9F=90=9B=20=EC=84=9C=EB=B2=84=20?=
=?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=85=20=EB=8F=99=EA=B8=B0?=
=?UTF-8?q?=ED=99=94=20=E2=80=94=20failedRows=20=EC=A0=9C=EA=B1=B0=20?=
=?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20UI=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UnivApplyInfosPageContent.tsx | 89 ++-----------------
.../univApplyInfoPreview.test.ts | 81 -----------------
.../univ-apply-infos/univApplyInfoPreview.ts | 19 ----
apps/admin/src/lib/api/admin.ts | 15 ----
4 files changed, 8 insertions(+), 196 deletions(-)
delete mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts
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 1bb3b8a8..9e17ee62 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
-import { type FormEvent, useEffect, useId, useState } from "react";
+import { type FormEvent, useId, useState } from "react";
import { toast } from "sonner";
import { AdminLayout } from "@/components/layout/AdminLayout";
import { Button } from "@/components/ui/button";
@@ -10,13 +10,8 @@ import { Textarea } from "@/components/ui/textarea";
import { adminApi, type UnivApplyInfoImportResponse } from "@/lib/api/admin";
import { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
-import {
- buildFailedCellMessages,
- buildPreviewRows,
- getPreviewCellError,
- parseMarkdownRow,
-} from "./univApplyInfoPreview";
-import { mergeErrorMaps, validatePreviewRows } from "./univApplyInfoValidation";
+import { buildPreviewRows, getPreviewCellError, parseMarkdownRow } from "./univApplyInfoPreview";
+import { validatePreviewRows } from "./univApplyInfoValidation";
function extractMarkdownHeaders(markdown: string): string[] {
const lines = markdown.trim().split("\n");
@@ -72,29 +67,13 @@ export function UnivApplyInfosPageContent() {
const importMutation = useMutation({
mutationFn: adminApi.importUnivApplyInfos,
onSuccess: (data) => {
- setShowPreviewModal(data.failedRows.length > 0);
+ setShowPreviewModal(false);
setImportResult(data);
- if (data.failedRows.length === 0) {
- toast.success(`${data.successCount}건 모두 추가됐습니다.`);
- } else {
- toast.warning(`성공 ${data.successCount}건, 실패 ${data.failedRows.length}건`);
- }
+ toast.success(`${data.successCount}건 모두 추가됐습니다.`);
},
onError: () => toast.error("지원 대학 추가에 실패했습니다."),
});
- useEffect(() => {
- const firstFailedRowNumber = importResult?.failedRows[0]?.rowNumber;
- if (!showPreviewModal || !firstFailedRowNumber) return;
-
- requestAnimationFrame(() => {
- const rowElement = document.querySelector(`[data-preview-row-number="${firstFailedRowNumber}"]`);
- if (typeof rowElement?.scrollIntoView === "function") {
- rowElement.scrollIntoView({ block: "center" });
- }
- });
- }, [importResult, showPreviewModal]);
-
const handleMarkdownChange = (e: React.ChangeEvent) => {
setMarkdown(e.target.value);
if (parsedHeaders.length > 0) {
@@ -150,7 +129,7 @@ export function UnivApplyInfosPageContent() {
const fields = fieldsQuery.data;
const mappedFieldSet = new Set(Object.values(columnMappings).filter(Boolean));
- const previewColumns = [
+ const previewColumns: { field: string; label: string; required: boolean; mapped: boolean }[] = [
// 필수 필드: 매핑 여부와 관계없이 항상 표시
...UNIV_APPLY_INFO_FIELDS.filter((f) => f.required).map((f) => ({
field: f.field,
@@ -174,20 +153,7 @@ export function UnivApplyInfosPageContent() {
const clientCellErrors = validatePreviewRows(previewRows);
// key format: "rowNumber:field:fieldName" — rowNumber is always the first segment
const clientErrorRowNumbers = new Set([...clientCellErrors.keys()].map((k) => Number(k.split(":")[0])));
- const failedRowNumbers = new Set(importResult?.failedRows.map((row) => row.rowNumber) ?? []);
- const failedCells = importResult?.failedRows.flatMap((row) => {
- if (row.errors.length === 0) {
- return [{ rowNumber: row.rowNumber, header: "-", value: "-", message: row.reason }];
- }
- return row.errors.map((error) => ({
- rowNumber: row.rowNumber,
- header: error.header || error.field || "-",
- value: error.value || "-",
- message: error.message || row.reason,
- }));
- });
- // server errors are second so they take priority on key collision (post-import ground truth)
- const failedCellMessages = mergeErrorMaps(clientCellErrors, buildFailedCellMessages(importResult));
+ const failedCellMessages = clientCellErrors;
return (
결과
성공 {importResult.successCount}건
- {importResult.failedRows.length > 0 && (
- <>
- {" "}
- / 실패 {importResult.failedRows.length}건
- >
- )}
{importResult.createdUniversities.length > 0 && (
@@ -365,34 +325,6 @@ export function UnivApplyInfosPageContent() {
)}
- {importResult.failedRows.length > 0 && (
-
-
-
-
- 행 번호
- 컬럼
- 입력값
- 실패 이유
-
-
-
- {failedCells?.map((error, index) => (
-
- {error.rowNumber}
- {error.header}
-
-
- {error.value}
-
-
- {error.message}
-
- ))}
-
-
-
- )}
)}
@@ -419,9 +351,6 @@ export function UnivApplyInfosPageContent() {
{clientErrorRowNumbers.size > 0 && (
오류 {clientErrorRowNumbers.size}행
)}
- {importResult && importResult.failedRows.length > 0 && (
- 실패 {importResult.failedRows.length}건
- )}
{row.rowNumber} |
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts
deleted file mode 100644
index a92dd601..00000000
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { buildFailedCellMessages, buildPreviewRows, getPreviewCellError } from "./univApplyInfoPreview";
-
-describe("univApplyInfoPreview", () => {
- it("matches server cell errors to preview cells by row number and original header", () => {
- const markdown = `
-| 대학명 | 국가코드 |
-|--------|----------|
-| | Belgium |
-`;
- const columnMappings = {
- 대학명: "universityKoreanName",
- 국가코드: "universityCountryCode",
- };
- const rows = buildPreviewRows(markdown, columnMappings);
- const failedCellMessages = buildFailedCellMessages({
- successCount: 0,
- createdUniversities: [],
- failedRows: [
- {
- rowNumber: 1,
- reason: "2개 컬럼에 문제가 있습니다.",
- errors: [
- {
- header: "대학명",
- field: "universityKoreanName",
- value: null,
- code: "REQUIRED",
- message: "대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다",
- },
- {
- header: "국가코드",
- field: "universityCountryCode",
- value: "Belgium",
- code: "NOT_FOUND",
- message: "국가를 찾을 수 없습니다.",
- },
- ],
- },
- ],
- });
-
- expect(rows[0]?.cellsByField.universityCountryCode?.value).toBe("Belgium");
- expect(getPreviewCellError(failedCellMessages, rows[0], "universityCountryCode")).toBe("국가를 찾을 수 없습니다.");
- });
-
- it("matches server cell errors when the original header is a required field label", () => {
- const markdown = `
-| 대학명 (국문) | 국가 코드 |
-|--------------|-----------|
-| | Belgium |
-`;
- const columnMappings = {
- "대학명 (국문)": "universityKoreanName",
- "국가 코드": "universityCountryCode",
- };
- const rows = buildPreviewRows(markdown, columnMappings);
- const failedCellMessages = buildFailedCellMessages({
- successCount: 0,
- createdUniversities: [],
- failedRows: [
- {
- rowNumber: 1,
- reason: "2개 컬럼에 문제가 있습니다.",
- errors: [
- {
- header: "국가 코드",
- field: "universityCountryCode",
- value: "Belgium",
- code: "NOT_FOUND",
- message: "국가를 찾을 수 없습니다.",
- },
- ],
- },
- ],
- });
-
- expect(rows[0]?.cellsByField.universityCountryCode?.value).toBe("Belgium");
- expect(getPreviewCellError(failedCellMessages, rows[0], "universityCountryCode")).toBe("국가를 찾을 수 없습니다.");
- });
-});
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
index 332d6851..8a9ba011 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoPreview.ts
@@ -1,4 +1,3 @@
-import type { UnivApplyInfoImportResponse } from "@/lib/api/admin";
import { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
export interface PreviewCell {
@@ -49,24 +48,6 @@ export function buildPreviewRows(markdown: string, columnMappings: Record {
- const failedCellMessages = new Map();
-
- importResult?.failedRows.forEach((row) => {
- row.errors.forEach((error) => {
- const message = error.message || row.reason;
- if (error.header) {
- failedCellMessages.set(`${row.rowNumber}:header:${error.header}`, message);
- }
- if (error.field) {
- failedCellMessages.set(`${row.rowNumber}:field:${error.field}`, message);
- }
- });
- });
-
- return failedCellMessages;
-}
-
export function getPreviewCellError(
failedCellMessages: Map,
row: PreviewRow,
diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts
index ca5fed84..3ea9565a 100644
--- a/apps/admin/src/lib/api/admin.ts
+++ b/apps/admin/src/lib/api/admin.ts
@@ -96,24 +96,9 @@ export interface UnivApplyInfoImportRequest {
export interface UnivApplyInfoImportResponse {
successCount: number;
- failedRows: UnivApplyInfoFailedRow[];
createdUniversities: string[];
}
-export interface UnivApplyInfoFailedRow {
- rowNumber: number;
- reason: string;
- errors: UnivApplyInfoCellError[];
-}
-
-export interface UnivApplyInfoCellError {
- header: string | null;
- field: string;
- value: string | null;
- code: string;
- message: string | null;
-}
-
const assignMentorApplicationUniversity = (mentorApplicationId: string | number, universityId: number) =>
axiosInstance
.post(`/admin/mentor-applications/${mentorApplicationId}/assign-university`, { universityId })
From f310a56c72f0135285d82d0ea069d05972b4d212 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Thu, 18 Jun 2026 00:13:20 +0900
Subject: [PATCH 28/33] =?UTF-8?q?chore:=20next=20=EB=8B=A4=EC=9A=B4?=
=?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C(=EB=A1=A4=EB=B0=B1)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index f0e883ad..14488662 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -38,7 +38,7 @@
"linkify-react": "^4.3.2",
"linkifyjs": "^4.3.2",
"lucide-react": "^0.479.0",
- "next": "^16.2.9",
+ "next": "^16.2.6",
"next-render-analyzer": "^0.1.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
From fa97d7f52da88e2eec3e81a6bbf82fcf1bde9c6e Mon Sep 17 00:00:00 2001
From: whqtker
Date: Thu, 18 Jun 2026 00:26:08 +0900
Subject: [PATCH 29/33] =?UTF-8?q?refactor:=20=EA=B5=AD=EA=B0=80=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A7=A4=ED=95=91=20=EC=83=81=EC=88=98=20?=
=?UTF-8?q?=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../univ-apply-infos/countryCodeAliases.ts | 88 +------------------
.../univ-apply-infos/countryCodeConstants.ts | 87 ++++++++++++++++++
2 files changed, 88 insertions(+), 87 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
index 995176b2..4633d0df 100644
--- a/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeAliases.ts
@@ -1,90 +1,4 @@
-export const COUNTRY_CODE_BY_NAME: Record = {
- // 한국어
- 오스트리아: "AT",
- 호주: "AU",
- 아제르바이잔: "AZ",
- 벨기에: "BE",
- 브루나이: "BN",
- 브라질: "BR",
- 캐나다: "CA",
- 콜롬비아: "CO",
- 스위스: "CH",
- 중국: "CN",
- 체코: "CZ",
- 에스토니아: "EE",
- 독일: "DE",
- 덴마크: "DK",
- 스페인: "ES",
- 핀란드: "FI",
- 프랑스: "FR",
- 영국: "GB",
- 아일랜드: "IE",
- 홍콩: "HK",
- 헝가리: "HU",
- 인도네시아: "ID",
- 이스라엘: "IL",
- 이탈리아: "IT",
- 일본: "JP",
- 카자흐스탄: "KZ",
- 리투아니아: "LT",
- 말레이시아: "MY",
- 네덜란드: "NL",
- 노르웨이: "NO",
- 포르투갈: "PT",
- 러시아: "RU",
- 스웨덴: "SE",
- 싱가포르: "SG",
- 태국: "TH",
- 튀르키예: "TR",
- 대만: "TW",
- 미국: "US",
- 우즈베키스탄: "UZ",
- // 영어 풀 네임
- Austria: "AT",
- Australia: "AU",
- Azerbaijan: "AZ",
- Belgium: "BE",
- Brunei: "BN",
- Brazil: "BR",
- Canada: "CA",
- Colombia: "CO",
- Switzerland: "CH",
- China: "CN",
- "Czech Republic": "CZ",
- Czechia: "CZ",
- Czech: "CZ",
- Estonia: "EE",
- Germany: "DE",
- Denmark: "DK",
- Spain: "ES",
- Finland: "FI",
- France: "FR",
- "United Kingdom": "GB",
- UK: "GB",
- "Hong Kong": "HK",
- Hungary: "HU",
- Ireland: "IE",
- Indonesia: "ID",
- Israel: "IL",
- Italy: "IT",
- Japan: "JP",
- Kazakhstan: "KZ",
- Lithuania: "LT",
- Malaysia: "MY",
- Netherlands: "NL",
- Norway: "NO",
- Portugal: "PT",
- Russia: "RU",
- Sweden: "SE",
- Singapore: "SG",
- Thailand: "TH",
- Turkey: "TR",
- Türkiye: "TR",
- Taiwan: "TW",
- "United States": "US",
- USA: "US",
- Uzbekistan: "UZ",
-};
+import { COUNTRY_CODE_BY_NAME } from "./countryCodeConstants";
export function resolveCountryCode(value: string): string {
return COUNTRY_CODE_BY_NAME[value.trim()] ?? value;
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts
new file mode 100644
index 00000000..057addc2
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts
@@ -0,0 +1,87 @@
+export const COUNTRY_CODE_BY_NAME: Record = {
+ // 한국어
+ 오스트리아: "AT",
+ 호주: "AU",
+ 아제르바이잔: "AZ",
+ 벨기에: "BE",
+ 브루나이: "BN",
+ 브라질: "BR",
+ 캐나다: "CA",
+ 콜롬비아: "CO",
+ 스위스: "CH",
+ 중국: "CN",
+ 체코: "CZ",
+ 에스토니아: "EE",
+ 독일: "DE",
+ 덴마크: "DK",
+ 스페인: "ES",
+ 핀란드: "FI",
+ 프랑스: "FR",
+ 영국: "GB",
+ 아일랜드: "IE",
+ 홍콩: "HK",
+ 헝가리: "HU",
+ 인도네시아: "ID",
+ 이스라엘: "IL",
+ 이탈리아: "IT",
+ 일본: "JP",
+ 카자흐스탄: "KZ",
+ 리투아니아: "LT",
+ 말레이시아: "MY",
+ 네덜란드: "NL",
+ 노르웨이: "NO",
+ 포르투갈: "PT",
+ 러시아: "RU",
+ 스웨덴: "SE",
+ 싱가포르: "SG",
+ 태국: "TH",
+ 튀르키예: "TR",
+ 대만: "TW",
+ 미국: "US",
+ 우즈베키스탄: "UZ",
+ // 영어 풀 네임
+ Austria: "AT",
+ Australia: "AU",
+ Azerbaijan: "AZ",
+ Belgium: "BE",
+ Brunei: "BN",
+ Brazil: "BR",
+ Canada: "CA",
+ Colombia: "CO",
+ Switzerland: "CH",
+ China: "CN",
+ "Czech Republic": "CZ",
+ Czechia: "CZ",
+ Czech: "CZ",
+ Estonia: "EE",
+ Germany: "DE",
+ Denmark: "DK",
+ Spain: "ES",
+ Finland: "FI",
+ France: "FR",
+ "United Kingdom": "GB",
+ UK: "GB",
+ "Hong Kong": "HK",
+ Hungary: "HU",
+ Ireland: "IE",
+ Indonesia: "ID",
+ Israel: "IL",
+ Italy: "IT",
+ Japan: "JP",
+ Kazakhstan: "KZ",
+ Lithuania: "LT",
+ Malaysia: "MY",
+ Netherlands: "NL",
+ Norway: "NO",
+ Portugal: "PT",
+ Russia: "RU",
+ Sweden: "SE",
+ Singapore: "SG",
+ Thailand: "TH",
+ Turkey: "TR",
+ Türkiye: "TR",
+ Taiwan: "TW",
+ "United States": "US",
+ USA: "US",
+ Uzbekistan: "UZ",
+};
From 90a298c77c603bac51ebf4103f98e5cea0301f4c Mon Sep 17 00:00:00 2001
From: whqtker
Date: Thu, 18 Jun 2026 11:17:55 +0900
Subject: [PATCH 30/33] =?UTF-8?q?=F0=9F=94=A7=20=EC=B6=94=EA=B0=80:=20?=
=?UTF-8?q?=EA=B5=AD=EA=B0=80=20=EC=BD=94=EB=93=9C=EC=97=90=20=EB=A7=88?=
=?UTF-8?q?=EC=B9=B4=EC=98=A4,=20=EB=AA=A8=EB=A1=9C=EC=BD=94,=20=EB=A9=95?=
=?UTF-8?q?=EC=8B=9C=EC=BD=94,=20=EB=89=B4=EC=A7=88=EB=9E=9C=EB=93=9C,=20?=
=?UTF-8?q?=ED=8F=B4=EB=9E=80=EB=93=9C,=20=EB=A3=A8=EB=A7=88=EB=8B=88?=
=?UTF-8?q?=EC=95=84,=20=EC=8A=AC=EB=A1=9C=EB=B2=A0=EB=8B=88=EC=95=84,=20?=
=?UTF-8?q?=EC=9A=B0=EB=A3=A8=EA=B3=BC=EC=9D=B4,=20=EB=B2=A0=ED=8A=B8?=
=?UTF-8?q?=EB=82=A8=20=EB=B0=8F=20=EC=A4=91=EA=B5=AD=20=EB=B3=B8=ED=86=A0?=
=?UTF-8?q?=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../univ-apply-infos/countryCodeConstants.ts | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts b/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts
index 057addc2..1b73fc4a 100644
--- a/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/countryCodeConstants.ts
@@ -27,17 +27,26 @@ export const COUNTRY_CODE_BY_NAME: Record = {
일본: "JP",
카자흐스탄: "KZ",
리투아니아: "LT",
+ 마카오: "MO",
+ 모로코: "MA",
말레이시아: "MY",
+ 멕시코: "MX",
네덜란드: "NL",
+ 뉴질랜드: "NZ",
노르웨이: "NO",
+ 폴란드: "PL",
포르투갈: "PT",
+ 루마니아: "RO",
러시아: "RU",
스웨덴: "SE",
싱가포르: "SG",
+ 슬로베니아: "SI",
태국: "TH",
튀르키예: "TR",
대만: "TW",
+ 우루과이: "UY",
미국: "US",
+ 베트남: "VN",
우즈베키스탄: "UZ",
// 영어 풀 네임
Austria: "AT",
@@ -78,10 +87,21 @@ export const COUNTRY_CODE_BY_NAME: Record = {
Sweden: "SE",
Singapore: "SG",
Thailand: "TH",
+ Macau: "MO",
+ Morocco: "MA",
+ Mexico: "MX",
+ "New Zealand": "NZ",
+ Poland: "PL",
+ Romania: "RO",
+ Slovenia: "SI",
Turkey: "TR",
Türkiye: "TR",
+ Turkiye: "TR",
Taiwan: "TW",
+ Uruguay: "UY",
"United States": "US",
USA: "US",
+ Vietnam: "VN",
Uzbekistan: "UZ",
+ "Mainland China": "CN",
};
From d4b5ec2d1ff3c42ef3bb10d804ab549a967c4a97 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Thu, 18 Jun 2026 11:19:27 +0900
Subject: [PATCH 31/33] =?UTF-8?q?chore:=20web=20next=20=EB=B2=84=EC=A0=84?=
=?UTF-8?q?=20=EC=97=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index 14488662..f0e883ad 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -38,7 +38,7 @@
"linkify-react": "^4.3.2",
"linkifyjs": "^4.3.2",
"lucide-react": "^0.479.0",
- "next": "^16.2.6",
+ "next": "^16.2.9",
"next-render-analyzer": "^0.1.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
From 94266feab979f5d23e2d18f999ed3e5da3bcb478 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Thu, 18 Jun 2026 14:25:14 +0900
Subject: [PATCH 32/33] =?UTF-8?q?=F0=9F=94=A7=20=EC=B6=94=EA=B0=80:=20Admi?=
=?UTF-8?q?nTermApiResponse=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?=
=?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20normalizeTermResponse=20=ED=95=A8?=
=?UTF-8?q?=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 | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts
index 3ea9565a..882d928a 100644
--- a/apps/admin/src/lib/api/admin.ts
+++ b/apps/admin/src/lib/api/admin.ts
@@ -79,6 +79,13 @@ export interface TermResponse {
isCurrent: boolean;
}
+interface AdminTermApiResponse {
+ id: number;
+ label?: string | null;
+ name?: string | null;
+ isCurrent: boolean;
+}
+
export interface TermCreatePayload {
name: string;
}
@@ -104,6 +111,14 @@ const assignMentorApplicationUniversity = (mentorApplicationId: string | number,
.post(`/admin/mentor-applications/${mentorApplicationId}/assign-university`, { universityId })
.then((res) => res.data);
+export function normalizeTermResponse(term: AdminTermApiResponse): TermResponse {
+ return {
+ id: term.id,
+ label: term.label ?? term.name ?? "",
+ isCurrent: term.isCurrent,
+ };
+}
+
export const adminApi = {
getMentorApplicationList: (params: MentorApplicationListParams) =>
axiosInstance.get("/admin/mentor-applications", { params }).then((res) => res.data),
@@ -178,10 +193,11 @@ export const adminApi = {
deleteHomeUniversity: (id: number) =>
axiosInstance.delete(`/admin/home-universities/${id}`).then((res) => res.data),
- getTerms: () => axiosInstance.get("/admin/terms").then((res) => res.data),
+ getTerms: () =>
+ axiosInstance.get("/admin/terms").then((res) => res.data.map(normalizeTermResponse)),
createTerm: (data: TermCreatePayload) =>
- axiosInstance.post("/admin/terms", data).then((res) => res.data),
+ axiosInstance.post("/admin/terms", data).then((res) => normalizeTermResponse(res.data)),
activateTerm: (id: number) => axiosInstance.patch(`/admin/terms/${id}/activate`).then((res) => res.data),
From d380e5c9e29b823b88f9bf9d12ab25875ab31278 Mon Sep 17 00:00:00 2001
From: whqtker
Date: Thu, 18 Jun 2026 14:50:34 +0900
Subject: [PATCH 33/33] =?UTF-8?q?=F0=9F=94=A7=20=EC=B6=94=EA=B0=80:=20Univ?=
=?UTF-8?q?ApplyInfoImportGuard=20=EB=B0=8F=20=ED=95=84=EC=88=98=20?=
=?UTF-8?q?=ED=95=84=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=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
---
.../UnivApplyInfosPageContent.tsx | 19 ++++++++++++++-----
.../univApplyInfoImportGuard.ts | 13 +++++++++++++
.../univApplyInfoValidation.ts | 15 +++++++++++++++
3 files changed, 42 insertions(+), 5 deletions(-)
create mode 100644 apps/admin/src/components/features/univ-apply-infos/univApplyInfoImportGuard.ts
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 9e17ee62..0bacef31 100644
--- a/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
+++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { adminApi, 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";
@@ -115,6 +116,13 @@ export function UnivApplyInfosPageContent() {
};
const handleConfirmImport = () => {
+ if (!canConfirmImport) {
+ if (previewRows.length === 0) {
+ toast.error("추가할 지원 대학이 없습니다.");
+ }
+ return;
+ }
+
const processedMarkdown = preprocessMarkdownCountryCodes(markdown.trim(), columnMappings);
importMutation.mutate({
homeUniversityId: Number(homeUniversityId),
@@ -154,6 +162,11 @@ export function UnivApplyInfosPageContent() {
// 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 (
setShowPreviewModal(false)}>
취소
- 0}
- >
+
{importMutation.isPending ? "추가 중..." : "추가"}
diff --git a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoImportGuard.ts b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoImportGuard.ts
new file mode 100644
index 00000000..c9233b9f
--- /dev/null
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoImportGuard.ts
@@ -0,0 +1,13 @@
+interface CanConfirmUnivApplyInfoImportParams {
+ previewRowCount: number;
+ clientErrorCount: number;
+ isPending: boolean;
+}
+
+export function canConfirmUnivApplyInfoImport({
+ previewRowCount,
+ clientErrorCount,
+ isPending,
+}: CanConfirmUnivApplyInfoImportParams): boolean {
+ return previewRowCount > 0 && clientErrorCount === 0 && !isPending;
+}
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 aeef5b38..a75ee8fa 100644
--- a/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
+++ b/apps/admin/src/components/features/univ-apply-infos/univApplyInfoValidation.ts
@@ -1,4 +1,5 @@
import { isValidCountryCode } from "./countryCodeAliases";
+import { UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
import type { PreviewRow } from "./univApplyInfoPreview";
const TUITION_FEE_TYPES = ["HOME_UNIVERSITY_PAYMENT", "OVERSEAS_UNIVERSITY_PAYMENT", "MIXED_PAYMENT"] as const;
@@ -58,6 +59,12 @@ const FIELD_RULES: Record = {
],
};
+const REQUIRED_FIELDS = UNIV_APPLY_INFO_FIELDS.filter((field) => field.required);
+
+function getRequiredFieldMessage(label: string): string {
+ return `필수 필드입니다: ${label}`;
+}
+
function validateCell(value: string, rules: FieldRule[]): string | undefined {
for (const rule of rules) {
const trimmed = value.trim();
@@ -94,9 +101,17 @@ export function validatePreviewRows(rows: PreviewRow[]): Map {
const errors = new Map();
for (const row of rows) {
+ for (const field of REQUIRED_FIELDS) {
+ const cell = row.cellsByField[field.field];
+ if (!cell?.value.trim()) {
+ errors.set(`${row.rowNumber}:field:${field.field}`, getRequiredFieldMessage(field.label));
+ }
+ }
+
for (const [field, cell] of Object.entries(row.cellsByField)) {
const rules = FIELD_RULES[field];
if (!rules) continue;
+ if (errors.has(`${row.rowNumber}:field:${field}`)) continue;
const message = validateCell(cell.value, rules);
if (message) {
errors.set(`${row.rowNumber}:field:${field}`, message);