Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a44e906
✨ 어드민 HomeUniversity·UnivApplyInfo API 타입 및 함수 추가
whqtker Jun 15, 2026
fbb75cd
✨ 어드민 사이드바에 협정 대학 관리·지원 대학 추가 메뉴 삽입
whqtker Jun 15, 2026
05eb614
✨ 협정 대학 관리 페이지 추가
whqtker Jun 15, 2026
ef21958
✨ 지원 대학 추가 페이지 추가
whqtker Jun 15, 2026
71826b1
🔧 superpowers 설계 문서 gitignore 추가
whqtker Jun 15, 2026
d917362
✨ 학기 선택 기능 추가 및 관련 API 통합
whqtker Jun 15, 2026
1cd8588
✨ 학기 관리 기능 추가 및 관련 API 통합
whqtker Jun 15, 2026
f85cf68
✨ 지원 대학 임포트 필드 매핑 개선
whqtker Jun 16, 2026
4b41f18
✨ 지원 대학 국가 코드 자동 변환 추가
whqtker Jun 16, 2026
d98b88f
🔧 vinext navigation alias 추가
whqtker Jun 16, 2026
adcbb59
📦 next.js 버전 업그레이드
whqtker Jun 16, 2026
db5f039
✨ 임포트 미리보기 모달 추가
whqtker Jun 16, 2026
6b0965c
✨ 임포트 미리보기 필수 컬럼 항상 표시 및 셀 값 말줄임 처리
whqtker Jun 16, 2026
8955de1
fix: 지원 대학 컬럼 매핑 규칙 수정
whqtker Jun 17, 2026
cc9bf36
fix: 지원 정보 임포트 오류 미리보기 개선
whqtker Jun 17, 2026
9f5112d
✨ 셀 단위 검증 클라이언트 로직 추가
whqtker Jun 17, 2026
24b9bf1
🐛 maxLength 검사를 trim 이후 길이 기준으로 수정
whqtker Jun 17, 2026
120a72a
✨ 임포트 미리보기 셀 검증 클라이언트 이전 및 추가 버튼 하드 블록
whqtker Jun 17, 2026
db4c0be
🎨 에러 맵 병합 순서 및 키 포맷 의도 주석 추가
whqtker Jun 17, 2026
ea5e6d7
✅ 컴포넌트 테스트를 클라이언트 검증 기반으로 업데이트
whqtker Jun 17, 2026
2c578a1
🔥 테스트 파일 삭제
whqtker Jun 17, 2026
75a24b7
✨ 컬럼 매핑 필드 추가 및 미리보기 비필수 컬럼 표시
whqtker Jun 17, 2026
fefa2ae
✨ 국가명 alias 추가 (Colombia, Czech, Estonia, Ireland)
whqtker Jun 17, 2026
fc5d6d1
✨ 국가명 alias 추가 (Belgium)
whqtker Jun 17, 2026
06aa95d
🔧 영문 대학명 200자, 기숙사·어학 세부 요건 2000자로 maxLength 조정
whqtker Jun 17, 2026
96fc23e
🔧 수강과목/지원자격/비고 maxLength 3000자로 조정
whqtker Jun 17, 2026
8cf689f
🐛 서버 응답 타입 동기화 — failedRows 제거 및 관련 UI 정리
whqtker Jun 17, 2026
f310a56
chore: next 다운그레이드(롤백)
whqtker Jun 17, 2026
fa97d7f
refactor: 국가 코드 매핑 상수 분리
whqtker Jun 17, 2026
90a298c
🔧 추가: 국가 코드에 마카오, 모로코, 멕시코, 뉴질랜드, 폴란드, 루마니아, 슬로베니아, 우루과이, 베트남 및 중국 본토 추가
whqtker Jun 18, 2026
d4b5ec2
chore: web next 버전 업
whqtker Jun 18, 2026
94266fe
🔧 추가: AdminTermApiResponse 인터페이스 및 normalizeTermResponse 함수 추가
whqtker Jun 18, 2026
d380e5c
🔧 추가: UnivApplyInfoImportGuard 및 필수 필드 검증 로직 추가
whqtker Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
packages/api-schema/src/apis/
packages/api-schema/.cache/

# superpowers design docs
docs/superpowers/

# misc
.DS_Store
*.pem
Expand Down
10 changes: 10 additions & 0 deletions apps/admin/src/app/home-universities/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession";
import { HomeUniversitiesPageContent } from "@/components/features/home-universities/HomeUniversitiesPageContent";

export default function HomeUniversitiesPage() {
return (
<RequireAdminSession>
<HomeUniversitiesPageContent />
</RequireAdminSession>
);
}
10 changes: 10 additions & 0 deletions apps/admin/src/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession";
import { TermsPageContent } from "@/components/features/terms/TermsPageContent";

export default function TermsPage() {
return (
<RequireAdminSession>
<TermsPageContent />
</RequireAdminSession>
);
}
10 changes: 10 additions & 0 deletions apps/admin/src/app/univ-apply-infos/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RequireAdminSession>
<UnivApplyInfosPageContent />
</RequireAdminSession>
);
}
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<AdminLayout
activeMenu="homeUniversities"
title="협정 대학 관리"
description="자교 협정 대학과 최대 지망 수를 관리합니다."
>
<div className="mt-4">
<section className="rounded-xl border border-k-100 bg-k-0 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="typo-sb-9 text-k-900">협정 대학</h2>
<p className="mt-1 typo-regular-4 text-k-500">예: 인하대학교</p>
</div>
<p className="typo-regular-4 text-k-500">총 {universities.length.toLocaleString()}건</p>
</div>

<form onSubmit={handleCreate} className="mt-4 grid gap-2 sm:grid-cols-[minmax(0,1fr)_140px_auto]">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="대학명" />
<Input
value={maxChoiceCount}
onChange={(e) => setMaxChoiceCount(e.target.value)}
placeholder="최대 지망 수"
type="number"
min={1}
/>
<Button type="submit" disabled={createMutation.isPending}>
생성
</Button>
</form>

<div className="mt-4 overflow-x-auto rounded-lg border border-k-100">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>대학명</TableHead>
<TableHead>최대 지망 수</TableHead>
<TableHead>작업</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center typo-regular-4 text-k-500">
불러오는 중...
</TableCell>
</TableRow>
) : query.isError ? (
<TableRow>
<TableCell colSpan={4} className="text-center typo-regular-4 text-magic-danger">
협정 대학을 불러오지 못했습니다.
</TableCell>
</TableRow>
) : universities.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center typo-regular-4 text-k-500">
협정 대학이 없습니다.
</TableCell>
</TableRow>
) : (
universities.map((univ) => {
const isEditing = editingId === univ.id;
return (
<TableRow key={univ.id} className="hover:bg-bg-50">
<TableCell>{univ.id}</TableCell>
<TableCell>
{isEditing ? (
<Input value={editingName} onChange={(e) => setEditingName(e.target.value)} />
) : (
univ.name
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={editingMaxChoiceCount}
onChange={(e) => setEditingMaxChoiceCount(e.target.value)}
type="number"
min={1}
className="w-24"
/>
) : (
univ.maxChoiceCount
)}
</TableCell>
<TableCell>
{isEditing ? (
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => handleUpdate(univ.id)} disabled={isMutating}>
저장
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>
취소
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleStartEdit(univ)}
disabled={isMutating}
>
수정
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(univ.id, univ.name)}
disabled={isMutating}
>
삭제
</Button>
</div>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</section>
</div>
</AdminLayout>
);
}
Loading
Loading