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 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/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/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/home-universities/HomeUniversitiesPageContent.tsx b/apps/admin/src/components/features/home-universities/HomeUniversitiesPageContent.tsx new file mode 100644 index 00000000..68169d9c --- /dev/null +++ b/apps/admin/src/components/features/home-universities/HomeUniversitiesPageContent.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type FormEvent, useState } from "react"; +import { toast } from "sonner"; +import { AdminLayout } from "@/components/layout/AdminLayout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { adminApi, type HomeUniversityResponse } from "@/lib/api/admin"; + +export function HomeUniversitiesPageContent() { + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [maxChoiceCount, setMaxChoiceCount] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(""); + const [editingMaxChoiceCount, setEditingMaxChoiceCount] = useState(""); + + const query = useQuery({ + queryKey: ["admin", "home-universities"], + queryFn: adminApi.getHomeUniversities, + placeholderData: keepPreviousData, + }); + + const universities = query.data ?? []; + + const invalidate = async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "home-universities"] }); + }; + + const createMutation = useMutation({ + mutationFn: adminApi.createHomeUniversity, + onSuccess: async () => { + await invalidate(); + setName(""); + setMaxChoiceCount(""); + toast.success("협정 대학을 생성했습니다."); + }, + onError: () => toast.error("협정 대학 생성에 실패했습니다."), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, ...data }: { id: number; name: string; maxChoiceCount: number }) => + adminApi.updateHomeUniversity(id, data), + onSuccess: async () => { + await invalidate(); + setEditingId(null); + toast.success("협정 대학을 수정했습니다."); + }, + onError: () => toast.error("협정 대학 수정에 실패했습니다."), + }); + + const deleteMutation = useMutation({ + mutationFn: adminApi.deleteHomeUniversity, + onSuccess: async () => { + await invalidate(); + toast.success("협정 대학을 삭제했습니다."); + }, + onError: () => toast.error("협정 대학 삭제에 실패했습니다."), + }); + + const handleCreate = (e: FormEvent) => { + e.preventDefault(); + const trimmedName = name.trim(); + const count = Number(maxChoiceCount); + if (!trimmedName || !Number.isInteger(count) || count < 1) { + toast.error("대학명과 최대 지망 수(1 이상)를 입력해주세요."); + return; + } + createMutation.mutate({ name: trimmedName, maxChoiceCount: count }); + }; + + const handleStartEdit = (univ: HomeUniversityResponse) => { + setEditingId(univ.id); + setEditingName(univ.name); + setEditingMaxChoiceCount(String(univ.maxChoiceCount)); + }; + + const handleUpdate = (id: number) => { + const trimmedName = editingName.trim(); + const count = Number(editingMaxChoiceCount); + if (!trimmedName || !Number.isInteger(count) || count < 1) { + toast.error("대학명과 최대 지망 수(1 이상)를 입력해주세요."); + return; + } + updateMutation.mutate({ id, name: trimmedName, maxChoiceCount: count }); + }; + + const handleDelete = (id: number, univName: string) => { + if (!window.confirm(`협정 대학 "${univName}"을 삭제할까요?`)) return; + deleteMutation.mutate(id); + }; + + const isMutating = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; + + return ( + +
+
+
+
+

협정 대학

+

예: 인하대학교

+
+

총 {universities.length.toLocaleString()}건

+
+ +
+ setName(e.target.value)} placeholder="대학명" /> + setMaxChoiceCount(e.target.value)} + placeholder="최대 지망 수" + type="number" + min={1} + /> + +
+ +
+ + + + ID + 대학명 + 최대 지망 수 + 작업 + + + + {query.isLoading ? ( + + + 불러오는 중... + + + ) : query.isError ? ( + + + 협정 대학을 불러오지 못했습니다. + + + ) : universities.length === 0 ? ( + + + 협정 대학이 없습니다. + + + ) : ( + universities.map((univ) => { + const isEditing = editingId === univ.id; + return ( + + {univ.id} + + {isEditing ? ( + setEditingName(e.target.value)} /> + ) : ( + univ.name + )} + + + {isEditing ? ( + setEditingMaxChoiceCount(e.target.value)} + type="number" + min={1} + className="w-24" + /> + ) : ( + univ.maxChoiceCount + )} + + + {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); + }) + )} +
+
+
+
+
+
+ ); +} 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 ( + +
+
+
+
+

학기

+

예: 2026-1

+
+

총 {terms.length.toLocaleString()}건

+
+ +
+ setName(event.target.value)} + placeholder="2026-1" + aria-label="학기 이름" + /> + +
+ +
+ + + + 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/features/univ-apply-infos/UnivApplyInfosPageContent.tsx b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx new file mode 100644 index 00000000..0bacef31 --- /dev/null +++ b/apps/admin/src/components/features/univ-apply-infos/UnivApplyInfosPageContent.tsx @@ -0,0 +1,441 @@ +"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 { 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"; +import { canConfirmUnivApplyInfoImport } from "./univApplyInfoImportGuard"; +import { buildPreviewRows, getPreviewCellError, parseMarkdownRow } from "./univApplyInfoPreview"; +import { validatePreviewRows } from "./univApplyInfoValidation"; + +function extractMarkdownHeaders(markdown: string): string[] { + const lines = markdown.trim().split("\n"); + if (lines.length < 2) return []; + const separatorPattern = /^\|[-| :]+\|$/; + if (!separatorPattern.test(lines[1].trim())) return []; + return parseMarkdownRow(lines[0]).filter((h) => h.length > 0); +} + +function buildAutoMappings(headers: string[], languageTestTypes: string[]): Record { + const mappings: Record = {}; + for (const header of headers) { + const field = findFieldByHeader(header); + if (field) { + mappings[header] = field; + continue; + } + if (languageTestTypes.includes(header)) { + mappings[header] = header; + } + } + return mappings; +} + +export function UnivApplyInfosPageContent() { + const homeUniversitySelectId = useId(); + const termSelectId = useId(); + + const [homeUniversityId, setHomeUniversityId] = useState(""); + const [termId, setTermId] = useState(""); + const [markdown, setMarkdown] = useState(""); + const [parsedHeaders, setParsedHeaders] = useState([]); + const [columnMappings, setColumnMappings] = useState>({}); + const [importResult, setImportResult] = useState(null); + const [showPreviewModal, setShowPreviewModal] = useState(false); + + const homeUniversitiesQuery = useQuery({ + queryKey: ["admin", "home-universities"], + queryFn: adminApi.getHomeUniversities, + }); + + const termsQuery = useQuery({ + queryKey: ["admin", "terms"], + queryFn: adminApi.getTerms, + }); + + const fieldsQuery = useQuery({ + queryKey: ["admin", "univ-apply-info-fields"], + queryFn: adminApi.getUnivApplyInfoFields, + staleTime: Number.POSITIVE_INFINITY, + }); + + const importMutation = useMutation({ + mutationFn: adminApi.importUnivApplyInfos, + onSuccess: (data) => { + setShowPreviewModal(false); + setImportResult(data); + toast.success(`${data.successCount}건 모두 추가됐습니다.`); + }, + onError: () => toast.error("지원 대학 추가에 실패했습니다."), + }); + + const handleMarkdownChange = (e: React.ChangeEvent) => { + setMarkdown(e.target.value); + if (parsedHeaders.length > 0) { + setParsedHeaders([]); + setColumnMappings({}); + setImportResult(null); + } + }; + + const handleParse = () => { + const headers = extractMarkdownHeaders(markdown); + if (headers.length === 0) { + toast.error("마크다운 헤더를 파싱할 수 없습니다. 형식을 확인해주세요."); + return; + } + const auto = buildAutoMappings(headers, fieldsQuery.data?.languageTestTypes ?? []); + setParsedHeaders(headers); + setColumnMappings(auto); + setImportResult(null); + }; + + const handleImport = (e: FormEvent) => { + e.preventDefault(); + const univId = Number(homeUniversityId); + const term = Number(termId); + if (!univId || !term) { + toast.error("협정 대학과 학기를 선택해주세요."); + return; + } + if (!markdown.trim()) { + toast.error("마크다운을 입력해주세요."); + return; + } + if (parsedHeaders.length === 0) { + toast.error("먼저 [파싱] 버튼을 눌러 컬럼을 확인해주세요."); + return; + } + setShowPreviewModal(true); + }; + + const handleConfirmImport = () => { + if (!canConfirmImport) { + if (previewRows.length === 0) { + toast.error("추가할 지원 대학이 없습니다."); + } + return; + } + + const processedMarkdown = preprocessMarkdownCountryCodes(markdown.trim(), columnMappings); + importMutation.mutate({ + homeUniversityId: Number(homeUniversityId), + termId: Number(termId), + markdown: processedMarkdown, + columnMappings, + }); + }; + + const universities = homeUniversitiesQuery.data ?? []; + const terms = termsQuery.data ?? []; + const fields = fieldsQuery.data; + + const mappedFieldSet = new Set(Object.values(columnMappings).filter(Boolean)); + const previewColumns: { field: string; label: string; required: boolean; mapped: boolean }[] = [ + // 필수 필드: 매핑 여부와 관계없이 항상 표시 + ...UNIV_APPLY_INFO_FIELDS.filter((f) => f.required).map((f) => ({ + field: f.field, + label: f.label, + required: true, + mapped: mappedFieldSet.has(f.field), + })), + // 비필수 시스템 필드: 매핑된 경우에만 표시 + ...UNIV_APPLY_INFO_FIELDS.filter((f) => !f.required && mappedFieldSet.has(f.field)).map((f) => ({ + field: f.field, + label: f.label, + required: false, + mapped: true, + })), + // 언어 시험 타입 컬럼 + ...[...mappedFieldSet] + .filter((f) => !UNIV_APPLY_INFO_FIELDS.some((sf) => sf.field === f)) + .map((f) => ({ field: f, label: f, required: false, mapped: true })), + ]; + const previewRows = showPreviewModal ? buildPreviewRows(markdown.trim(), columnMappings) : []; + const clientCellErrors = validatePreviewRows(previewRows); + // key format: "rowNumber:field:fieldName" — rowNumber is always the first segment + const clientErrorRowNumbers = new Set([...clientCellErrors.keys()].map((k) => Number(k.split(":")[0]))); + const failedCellMessages = clientCellErrors; + const canConfirmImport = canConfirmUnivApplyInfoImport({ + previewRowCount: previewRows.length, + clientErrorCount: clientCellErrors.size, + isPending: importMutation.isPending, + }); + + return ( + +
+ {/* ① 기본 정보 */} +
+

① 기본 정보

+
+
+ + + {homeUniversitiesQuery.isLoading &&

불러오는 중...

} + {homeUniversitiesQuery.isError && ( +

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

+ )} +
+
+ + + {termsQuery.isLoading &&

불러오는 중...

} + {termsQuery.isError && ( +

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

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

② 마크다운 입력

+

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

+