diff --git a/apps/admin/src/app/regions-countries/page.tsx b/apps/admin/src/app/regions-countries/page.tsx new file mode 100644 index 00000000..d4cbe384 --- /dev/null +++ b/apps/admin/src/app/regions-countries/page.tsx @@ -0,0 +1,10 @@ +import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession"; +import { RegionsCountriesPageContent } from "@/components/features/regions-countries/RegionsCountriesPageContent"; + +export default function RegionsCountriesPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/features/mentor-applications/MentorApplicationsPageContent.tsx b/apps/admin/src/components/features/mentor-applications/MentorApplicationsPageContent.tsx index 58155fb5..c9615163 100644 --- a/apps/admin/src/components/features/mentor-applications/MentorApplicationsPageContent.tsx +++ b/apps/admin/src/components/features/mentor-applications/MentorApplicationsPageContent.tsx @@ -123,6 +123,12 @@ const getUniversityName = (application: MentorApplicationListItem) => { return pickString(core.universityName, university?.koreanName, university?.name, core.universityId, university?.id); }; +const getUniversityId = (application: MentorApplicationListItem) => { + const core = getApplicationCore(application); + const university = getUniversity(application); + return pickString(core.universityId, university?.universityId, university?.id); +}; + const getTerm = (application: MentorApplicationListItem) => { const core = getApplicationCore(application); return pickString(core.term); @@ -177,6 +183,46 @@ const getHistoryItems = (data: MentorApplicationHistoryResponse | undefined) => return Array.isArray(data) ? data : (data.content ?? []); }; +type MentorApplicationCountData = Awaited>; + +const toCountNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + if (typeof value === "object" && value !== null) { + const record = value as Record; + return toCountNumber(record.count ?? record.total ?? record.value); + } + + return undefined; +}; + +const getCountByStatus = (data: MentorApplicationCountData | undefined, status: MentorApplicationStatus) => { + if (!data) return undefined; + + if (Array.isArray(data)) { + const item = data.find( + (entry) => normalizeStatus(entry.mentorApplicationStatus ?? entry.status ?? entry.name) === status, + ); + return toCountNumber(item); + } + + const record = data as Record; + const collection = record.content ?? record.data ?? record.items ?? record.result; + if (Array.isArray(collection)) { + const item = collection.find((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const nextRecord = entry as Record; + return normalizeStatus(nextRecord.mentorApplicationStatus ?? nextRecord.status ?? nextRecord.name) === status; + }); + return toCountNumber(item); + } + + return toCountNumber(record[status] ?? record[status.toLowerCase()]); +}; + function MentorApplicationStatusBadge({ status }: { status: MentorApplicationStatus | null }) { if (!status) { return -; @@ -265,6 +311,8 @@ export function MentorApplicationsPageContent() { const [expandedSiteUserId, setExpandedSiteUserId] = useState(null); const [rejectingApplicationId, setRejectingApplicationId] = useState(null); const [rejectReason, setRejectReason] = useState(""); + const [mappingApplicationId, setMappingApplicationId] = useState(null); + const [mappingUniversityId, setMappingUniversityId] = useState(""); const normalizedNickname = nickname.trim(); @@ -281,12 +329,24 @@ export function MentorApplicationsPageContent() { placeholderData: keepPreviousData, }); + const countQuery = useQuery({ + queryKey: ["mentorApplications", "count"], + queryFn: adminApi.getCountMentorApplicationByStatus, + placeholderData: keepPreviousData, + }); + useEffect(() => { if (isError) { toast.error("멘토 승격 요청 목록을 불러오지 못했습니다."); } }, [isError]); + useEffect(() => { + if (countQuery.isError) { + toast.error("멘토 승격 요청 상태별 개수를 불러오지 못했습니다."); + } + }, [countQuery.isError]); + const applications = data?.content ?? []; const totalPages = Math.max(1, data?.totalPages ?? 1); const totalElements = data?.totalElements; @@ -316,6 +376,20 @@ export function MentorApplicationsPageContent() { }, }); + const mapUniversityMutation = useMutation({ + mutationFn: ({ mentorApplicationId, universityId }: { mentorApplicationId: string; universityId: number }) => + adminApi.assignMentorApplicationUniversity(mentorApplicationId, universityId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["mentorApplications"] }); + setMappingApplicationId(null); + setMappingUniversityId(""); + toast.success("멘토 지원서 대학을 매핑했습니다."); + }, + onError: () => { + toast.error("멘토 지원서 대학 매핑에 실패했습니다."); + }, + }); + const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setPage(newPage); @@ -372,12 +446,61 @@ export function MentorApplicationsPageContent() { await rejectMutation.mutateAsync({ mentorApplicationId, rejectedReason: normalizedReason }); }; + const handleStartMapUniversity = (application: MentorApplicationListItem) => { + const applicationId = getApplicationId(application); + if (!applicationId) { + toast.error("신청 ID를 확인할 수 없습니다."); + return; + } + + setMappingApplicationId(applicationId); + setMappingUniversityId(getUniversityId(application) ?? ""); + }; + + const handleCancelMapUniversity = () => { + setMappingApplicationId(null); + setMappingUniversityId(""); + }; + + const handleMapUniversity = async (mentorApplicationId: string) => { + const normalizedUniversityId = Number(mappingUniversityId.trim()); + if (!Number.isInteger(normalizedUniversityId) || normalizedUniversityId <= 0) { + toast.error("대학 ID를 숫자로 입력해주세요."); + return; + } + + await mapUniversityMutation.mutateAsync({ mentorApplicationId, universityId: normalizedUniversityId }); + }; + return ( +
+ {STATUS_OPTIONS.map((option) => { + const count = getCountByStatus(countQuery.data, option.value); + const isActive = statusFilter === option.value; + + return ( + + ); + })} +
+
{formatDateTime(getCreatedAt(application))} @@ -560,7 +687,28 @@ export function MentorApplicationsPageContent() { {status === "PENDING" && applicationId ? ( - isRejecting ? ( + isMappingUniversity ? ( +
+ setMappingUniversityId(event.target.value)} + placeholder="대학 ID" + className="h-8 w-[120px] rounded-md border border-k-200 bg-k-0 px-2 typo-regular-4 text-k-700 outline-none placeholder:text-k-400 focus-visible:border-primary" + /> + + +
+ ) : isRejecting ? (
) : (
+ + + +
+ + + + 코드 + 이름 + 작업 + + + + {regionsQuery.isLoading ? ( + + + 권역을 불러오는 중... + + + ) : regionsQuery.isError ? ( + + + 권역을 불러오지 못했습니다. + + + ) : regions.length === 0 ? ( + + + 권역이 없습니다. + + + ) : ( + regions.map((region, index) => { + const code = getRegionCode(region); + const isEditing = Boolean(code && editingRegionCode === code); + + return ( + + {toDisplayText(code)} + + {isEditing ? ( + setEditingRegionName(event.target.value)} + /> + ) : ( + toDisplayText(getRegionName(region)) + )} + + + {isEditing && code ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); + }) + )} +
+
+
+ + +
+
+
+

지역

+

예: AT, US, JP

+
+

총 {countries.length.toLocaleString()}건

+
+ +
+ setCountryCode(event.target.value)} + placeholder="지역 코드" + /> + setCountryName(event.target.value)} + placeholder="지역 이름" + /> + + +
+ +
+ + + + 코드 + 이름 + 권역 + 작업 + + + + {countriesQuery.isLoading ? ( + + + 지역을 불러오는 중... + + + ) : countriesQuery.isError ? ( + + + 지역을 불러오지 못했습니다. + + + ) : countries.length === 0 ? ( + + + 지역이 없습니다. + + + ) : ( + countries.map((country, index) => { + const code = getCountryCode(country); + const isEditing = Boolean(code && editingCountryCode === code); + + return ( + + {toDisplayText(code)} + + {isEditing ? ( + setEditingCountryName(event.target.value)} + /> + ) : ( + toDisplayText(getCountryName(country)) + )} + + + {isEditing ? ( + + ) : ( + toDisplayText(getCountryRegionCode(country)) + )} + + + {isEditing && code ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); + }) + )} +
+
+
+
+
+ + ); +} diff --git a/apps/admin/src/components/layout/AdminSidebar.tsx b/apps/admin/src/components/layout/AdminSidebar.tsx index c6bab38b..d4aadf8a 100644 --- a/apps/admin/src/components/layout/AdminSidebar.tsx +++ b/apps/admin/src/components/layout/AdminSidebar.tsx @@ -1,11 +1,12 @@ -import { FileText, FlaskConical, MessageSquare, UserCheck } from "lucide-react"; +import { FileText, FlaskConical, MapPinned, MessageSquare, UserCheck } from "lucide-react"; import { cn } from "@/lib/utils"; -export type ActiveAdminMenu = "scores" | "mentorApplications" | "bruno" | "chatSocket"; +export type ActiveAdminMenu = "scores" | "mentorApplications" | "regionsCountries" | "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: "bruno", label: "Bruno API", icon: FlaskConical, to: "/bruno" as const }, { key: "chatSocket", label: "채팅 소켓", icon: MessageSquare, to: "/chat-socket" as const }, ] as const; diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts index a8373dba..6ebad569 100644 --- a/apps/admin/src/lib/api/admin.ts +++ b/apps/admin/src/lib/api/admin.ts @@ -5,6 +5,29 @@ import type { MentorApplicationStatus, } from "@/types/mentorApplications"; +export interface AdminCollectionResponse { + content?: T[]; + data?: T[]; + items?: T[]; + result?: T[]; +} + +export type AdminCollection = T[] | AdminCollectionResponse; + +export interface MentorApplicationCountItem { + mentorApplicationStatus?: MentorApplicationStatus | string | null; + status?: MentorApplicationStatus | string | null; + name?: string | null; + count?: number | string | null; + total?: number | string | null; + value?: number | string | null; +} + +export type MentorApplicationCountResponse = + | Partial> + | MentorApplicationCountItem[] + | AdminCollectionResponse; + export interface MentorApplicationListParams { page?: number; size?: number; @@ -13,25 +36,43 @@ export interface MentorApplicationListParams { createdAt?: string; } +export interface RegionResponse { + code?: string | null; + regionCode?: string | null; + koreanName?: string | null; + name?: string | null; +} + export interface RegionPayload { code?: string; koreanName: string; } +export interface CountryResponse { + code?: string | null; + koreanName?: string | null; + name?: string | null; + regionCode?: string | null; + region?: string | RegionResponse | null; +} + export interface CountryPayload { code?: string; koreanName: string; regionCode: string; } +const assignMentorApplicationUniversity = (mentorApplicationId: string | number, universityId: number) => + axiosInstance + .post(`/admin/mentor-applications/${mentorApplicationId}/assign-university`, { universityId }) + .then((res) => res.data); + export const adminApi = { getMentorApplicationList: (params: MentorApplicationListParams) => axiosInstance.get("/admin/mentor-applications", { params }).then((res) => res.data), getCountMentorApplicationByStatus: () => - axiosInstance - .get>>("/admin/mentor-applications/count") - .then((res) => res.data), + axiosInstance.get("/admin/mentor-applications/count").then((res) => res.data), getMentorApplicationHistoryList: (siteUserId: string | number) => axiosInstance @@ -46,12 +87,31 @@ export const adminApi = { .post(`/admin/mentor-applications/${mentorApplicationId}/reject`, { rejectedReason }) .then((res) => res.data), - postMappingMentorapplicationUniversity: (mentorApplicationId: string | number, universityId: number) => - axiosInstance - .post(`/admin/mentor-applications/${mentorApplicationId}/assign-university`, { universityId }) - .then((res) => res.data), + postMappingMentorapplicationUniversity: assignMentorApplicationUniversity, + + assignMentorApplicationUniversity, + + getRegions: () => axiosInstance.get>("/admin/regions").then((res) => res.data), + + createRegion: (data: RegionPayload) => + axiosInstance.post("/admin/regions", data).then((res) => res.data), + + updateRegion: (code: string, data: RegionPayload) => + axiosInstance.put(`/admin/regions/${code}`, data).then((res) => res.data), + + deleteRegion: (code: string) => axiosInstance.delete(`/admin/regions/${code}`).then((res) => res.data), + + getCountries: () => axiosInstance.get>("/admin/countries").then((res) => res.data), + + createCountry: (data: CountryPayload) => + axiosInstance.post("/admin/countries", data).then((res) => res.data), + + updateCountry: (code: string, data: CountryPayload) => + axiosInstance.put(`/admin/countries/${code}`, data).then((res) => res.data), + + deleteCountry: (code: string) => axiosInstance.delete(`/admin/countries/${code}`).then((res) => res.data), - get권역조회: () => axiosInstance.get("/admin/regions").then((res) => res.data), + get권역조회: () => axiosInstance.get>("/admin/regions").then((res) => res.data), post권역생성: (data: RegionPayload) => axiosInstance.post("/admin/regions", data).then((res) => res.data), @@ -60,7 +120,7 @@ export const adminApi = { delete권역삭제: (code: string) => axiosInstance.delete(`/admin/regions/${code}`).then((res) => res.data), - get지역조회: () => axiosInstance.get("/admin/countries").then((res) => res.data), + get지역조회: () => axiosInstance.get>("/admin/countries").then((res) => res.data), post지역생성: (data: CountryPayload) => axiosInstance.post("/admin/countries", data).then((res) => res.data),