From acfdd0188fc6bc93ed5af4c4d08beb0d27173373 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sat, 21 Feb 2026 01:46:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B6=84=EB=A6=AC=EC=99=80=20=EC=84=B1=EC=A0=81=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20UX=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AdminLayout.tsx | 18 +- apps/admin/src/components/ui/card.tsx | 8 +- apps/admin/src/routeTree.gen.ts | 24 ++- apps/admin/src/routes/__root.tsx | 4 +- apps/admin/src/routes/auth/login.tsx | 37 ++-- apps/admin/src/routes/login.tsx | 7 + apps/admin/src/routes/scores/index.tsx | 180 +++++------------- apps/admin/src/styles.css | 6 +- 8 files changed, 117 insertions(+), 167 deletions(-) create mode 100644 apps/admin/src/routes/login.tsx diff --git a/apps/admin/src/components/layout/AdminLayout.tsx b/apps/admin/src/components/layout/AdminLayout.tsx index d60d44a5..f1be337c 100644 --- a/apps/admin/src/components/layout/AdminLayout.tsx +++ b/apps/admin/src/components/layout/AdminLayout.tsx @@ -4,8 +4,22 @@ interface AdminLayoutProps { export function AdminLayout({ children }: AdminLayoutProps) { return ( -
-
{children}
+
+
+
+
+
+ SC +
+
+

Solid Connection

+

Admin

+
+
+

운영 콘솔

+
+
{children}
+
); } diff --git a/apps/admin/src/components/ui/card.tsx b/apps/admin/src/components/ui/card.tsx index 63ba9666..90091311 100644 --- a/apps/admin/src/components/ui/card.tsx +++ b/apps/admin/src/components/ui/card.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const Card = React.forwardRef>(({ className, ...props }, ref) => ( -
+
)); Card.displayName = "Card"; @@ -16,15 +16,13 @@ CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
), ); CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), + ({ className, ...props }, ref) =>
, ); CardDescription.displayName = "CardDescription"; diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index add56e7f..88db48fb 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -9,10 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as ScoresIndexRouteImport } from './routes/scores/index' import { Route as AuthLoginRouteImport } from './routes/auth/login' +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -31,36 +37,47 @@ const AuthLoginRoute = AuthLoginRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/login': typeof LoginRoute '/auth/login': typeof AuthLoginRoute '/scores/': typeof ScoresIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/login': typeof LoginRoute '/auth/login': typeof AuthLoginRoute '/scores': typeof ScoresIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/login': typeof LoginRoute '/auth/login': typeof AuthLoginRoute '/scores/': typeof ScoresIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/auth/login' | '/scores/' + fullPaths: '/' | '/login' | '/auth/login' | '/scores/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/auth/login' | '/scores' - id: '__root__' | '/' | '/auth/login' | '/scores/' + to: '/' | '/login' | '/auth/login' | '/scores' + id: '__root__' | '/' | '/login' | '/auth/login' | '/scores/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + LoginRoute: typeof LoginRoute AuthLoginRoute: typeof AuthLoginRoute ScoresIndexRoute: typeof ScoresIndexRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -87,6 +104,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + LoginRoute: LoginRoute, AuthLoginRoute: AuthLoginRoute, ScoresIndexRoute: ScoresIndexRoute, } diff --git a/apps/admin/src/routes/__root.tsx b/apps/admin/src/routes/__root.tsx index 23718ea8..a6f1e9d2 100644 --- a/apps/admin/src/routes/__root.tsx +++ b/apps/admin/src/routes/__root.tsx @@ -3,8 +3,6 @@ import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { Toaster } from "sonner"; -import { AdminLayout } from "@/components/layout/AdminLayout"; - import appCss from "../styles.css?url"; export const Route = createRootRoute({ @@ -39,7 +37,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { - {children} + {children} - - - 관리자 로그인 - 솔리드 커넥션 관리자 페이지입니다 +
+ + +
+ SC +
+ 관리자 로그인 + + 솔리드 커넥션 운영 콘솔에 접속합니다 +
-
+
-
-
-
diff --git a/apps/admin/src/routes/login.tsx b/apps/admin/src/routes/login.tsx new file mode 100644 index 00000000..92b693e4 --- /dev/null +++ b/apps/admin/src/routes/login.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/login")({ + beforeLoad: () => { + throw redirect({ to: "/auth/login" }); + }, +}); diff --git a/apps/admin/src/routes/scores/index.tsx b/apps/admin/src/routes/scores/index.tsx index d37c217f..80fbfa08 100644 --- a/apps/admin/src/routes/scores/index.tsx +++ b/apps/admin/src/routes/scores/index.tsx @@ -1,12 +1,9 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { Building2, FileText, Search, SquarePen, UserCircle2 } from "lucide-react"; -import { useState } from "react"; +import { useId, useState } from "react"; import { GpaScoreTable } from "@/components/features/scores/GpaScoreTable"; import { LanguageScoreTable } from "@/components/features/scores/LanguageScoreTable"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { AdminLayout } from "@/components/layout/AdminLayout"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "@/lib/utils"; import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken } from "@/lib/utils/localStorage"; import type { VerifyStatus } from "@/types/scores"; @@ -26,141 +23,56 @@ export const Route = createFileRoute("/scores/")({ function ScoresPage() { const [verifyFilter, setVerifyFilter] = useState("PENDING"); - const [searchKeyword, setSearchKeyword] = useState(""); - - const sideMenus = [ - { label: "대학 관리", icon: Building2, active: true }, - { label: "멘토 관리", icon: UserCircle2, active: false }, - { label: "유저 관리", icon: UserCircle2, active: false }, - { label: "성적 관리", icon: FileText, active: false }, - ] as const; - - const topTabs = ["권역/나라", "나라/대학", "대학 지원 정보", "대학 상세 정보"] as const; - - const verifyFilters: Array<{ value: VerifyStatus; label: string }> = [ - { value: "PENDING", label: "대기중" }, - { value: "APPROVED", label: "승인됨" }, - { value: "REJECTED", label: "거절됨" }, - ]; + const verifyFilterId = useId(); return ( -
-
- - -
-
-
- {topTabs.map((tab) => ( - - ))} -
+ +
+

성적 관리

-
-

권역/나라

+
+ + +
-
- setSearchKeyword(event.target.value)} - placeholder="검색어를 입력한 후 검색할 카테고리를 선택해주세요" - className="h-9 border-k-100 bg-bg-50 pr-10 typo-regular-4 text-k-700" - /> - -
- - -
- -
- {verifyFilters.map((option) => ( - - ))} -
- -
- - - - GPA 성적 - - - 어학성적 - - +
+ + + + GPA 성적 + + + 어학성적 + + - - - + + + - - - - -
-
-
+ + + + +
-
+ ); } diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index a873aaf3..2fe9bc3d 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -2,15 +2,13 @@ body { @apply m-0; - font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-family: "Pretendard", system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } @layer utilities { From 826f7bc89048f0d07b172af644bf9f8cc3b670b9 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sat, 21 Feb 2026 02:16:53 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20Reac?= =?UTF-8?q?t=20Query=20=EA=B3=B5=ED=86=B5=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/package.json | 7 ++++--- .../components/providers/QueryProvider.tsx | 13 ++++++++++++ apps/admin/src/lib/query/queryClient.ts | 21 +++++++++++++++++++ apps/admin/src/routes/__root.tsx | 3 ++- pnpm-lock.yaml | 3 +++ 5 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 apps/admin/src/components/providers/QueryProvider.tsx create mode 100644 apps/admin/src/lib/query/queryClient.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index 0d5b3669..2f556981 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -22,9 +22,10 @@ "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", - "@tanstack/react-router": "^1.132.0", - "@tanstack/react-router-devtools": "^1.132.0", - "@tanstack/react-router-ssr-query": "^1.131.7", + "@tanstack/react-router": "^1.132.0", + "@tanstack/react-router-devtools": "^1.132.0", + "@tanstack/react-query": "^5.84.1", + "@tanstack/react-router-ssr-query": "^1.131.7", "@tanstack/react-start": "^1.132.0", "@tanstack/router-plugin": "^1.132.0", "axios": "^1.6.7", diff --git a/apps/admin/src/components/providers/QueryProvider.tsx b/apps/admin/src/components/providers/QueryProvider.tsx new file mode 100644 index 00000000..99e4009f --- /dev/null +++ b/apps/admin/src/components/providers/QueryProvider.tsx @@ -0,0 +1,13 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { createQueryClient } from "@/lib/query/queryClient"; + +interface QueryProviderProps { + children: ReactNode; +} + +export function QueryProvider({ children }: QueryProviderProps) { + const [queryClient] = useState(createQueryClient); + + return {children}; +} diff --git a/apps/admin/src/lib/query/queryClient.ts b/apps/admin/src/lib/query/queryClient.ts new file mode 100644 index 00000000..8a1cd128 --- /dev/null +++ b/apps/admin/src/lib/query/queryClient.ts @@ -0,0 +1,21 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +export const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + const status = (error as AxiosError | undefined)?.response?.status; + if (status === 401 || status === 403) { + return false; + } + + return failureCount < 1; + }, + }, + }, + }); diff --git a/apps/admin/src/routes/__root.tsx b/apps/admin/src/routes/__root.tsx index a6f1e9d2..9a4b8a72 100644 --- a/apps/admin/src/routes/__root.tsx +++ b/apps/admin/src/routes/__root.tsx @@ -2,6 +2,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { Toaster } from "sonner"; +import { QueryProvider } from "@/components/providers/QueryProvider"; import appCss from "../styles.css?url"; @@ -37,7 +38,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { - {children} + {children} Date: Sat, 21 Feb 2026 02:16:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EA=B3=BC=20=EC=84=B1=EC=A0=81=20?= =?UTF-8?q?=EA=B2=80=EC=88=98=20API=EB=A5=BC=20React=20Query=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/scores/GpaScoreTable.tsx | 73 +- .../features/scores/LanguageScoreTable.tsx | 73 +- apps/admin/src/lib/api/client.ts | 10 +- apps/admin/src/routes/auth/login.tsx | 14 +- apps/admin/src/routes/scores/index.tsx | 665 ++---------------- 5 files changed, 153 insertions(+), 682 deletions(-) diff --git a/apps/admin/src/components/features/scores/GpaScoreTable.tsx b/apps/admin/src/components/features/scores/GpaScoreTable.tsx index 4ecea310..5d3855db 100644 --- a/apps/admin/src/components/features/scores/GpaScoreTable.tsx +++ b/apps/admin/src/components/features/scores/GpaScoreTable.tsx @@ -1,5 +1,6 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -12,39 +13,49 @@ interface Props { verifyFilter: VerifyStatus; } -const S3_BASE_URL = import.meta.env.VITE_S3_BASE_URL; +const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || ""; export function GpaScoreTable({ verifyFilter }: Props) { - const [scores, setScores] = useState([]); + const queryClient = useQueryClient(); const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [loading, setLoading] = useState(false); const [editingId, setEditingId] = useState(null); const [editingGpa, setEditingGpa] = useState(0); const [editingGpaCriteria, setEditingGpaCriteria] = useState(0); - const fetchScores = useCallback(async () => { - setLoading(true); - try { - const response = await scoreApi.getGpaScores({ verifyStatus: verifyFilter }, page); - setScores(response.content); - setTotalPages(response.totalPages); - } catch (error) { - console.error("Failed to fetch GPA scores:", error); - } finally { - setLoading(false); - } - }, [verifyFilter, page]); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ["scores", "gpa", verifyFilter, page], + queryFn: () => scoreApi.getGpaScores({ verifyStatus: verifyFilter }, page), + placeholderData: keepPreviousData, + }); - useEffect(() => { - fetchScores(); - }, [fetchScores]); + const updateGpaMutation = useMutation({ + mutationFn: ({ + id, + status, + reason, + score, + }: { + id: number; + status: VerifyStatus; + reason?: string; + score: GpaScoreWithUser; + }) => scoreApi.updateGpaScore(id, status, reason, score), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["scores", "gpa"] }); + }, + }); + + const scores = data?.content ?? []; + const totalPages = data?.totalPages ?? 1; const handleVerifyStatus = async (id: number, status: VerifyStatus, reason?: string) => { try { const score = scores.find((s) => s.gpaScoreStatusResponse.id === id); - await scoreApi.updateGpaScore(id, status, reason, score); - fetchScores(); + if (!score) { + throw new Error("Score data is required"); + } + + await updateGpaMutation.mutateAsync({ id, status, reason, score }); } catch (error) { console.error("Failed to update GPA score:", error); toast.error("성적 상태 업데이트에 실패했습니다"); @@ -59,11 +70,11 @@ export function GpaScoreTable({ verifyFilter }: Props) { const handleSave = async (score: GpaScoreWithUser) => { try { - await scoreApi.updateGpaScore( - score.gpaScoreStatusResponse.id, - score.gpaScoreStatusResponse.verifyStatus, - score.gpaScoreStatusResponse.rejectedReason || undefined, - { + await updateGpaMutation.mutateAsync({ + id: score.gpaScoreStatusResponse.id, + status: score.gpaScoreStatusResponse.verifyStatus, + reason: score.gpaScoreStatusResponse.rejectedReason || undefined, + score: { ...score, gpaScoreStatusResponse: { ...score.gpaScoreStatusResponse, @@ -74,9 +85,8 @@ export function GpaScoreTable({ verifyFilter }: Props) { }, }, }, - ); + }); setEditingId(null); - fetchScores(); toast.success("GPA가 수정되었습니다"); } catch (error) { console.error("Failed to update GPA:", error); @@ -91,6 +101,9 @@ export function GpaScoreTable({ verifyFilter }: Props) { return (
+ {isFetching && !isLoading ? ( +
최신 데이터를 불러오는 중...
+ ) : null}
@@ -107,7 +120,7 @@ export function GpaScoreTable({ verifyFilter }: Props) { - {loading ? ( + {isLoading ? (
diff --git a/apps/admin/src/components/features/scores/LanguageScoreTable.tsx b/apps/admin/src/components/features/scores/LanguageScoreTable.tsx index 344a6a83..6009a6f0 100644 --- a/apps/admin/src/components/features/scores/LanguageScoreTable.tsx +++ b/apps/admin/src/components/features/scores/LanguageScoreTable.tsx @@ -1,5 +1,6 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -12,7 +13,7 @@ interface Props { verifyFilter: VerifyStatus; } -const S3_BASE_URL = import.meta.env.VITE_S3_BASE_URL; +const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || ""; const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [ { value: "TOEIC", label: "TOEIC" }, @@ -30,36 +31,46 @@ const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [ ]; export function LanguageScoreTable({ verifyFilter }: Props) { - const [scores, setScores] = useState([]); + const queryClient = useQueryClient(); const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [loading, setLoading] = useState(false); const [editingId, setEditingId] = useState(null); const [editingScore, setEditingScore] = useState(""); const [editingType, setEditingType] = useState("TOEIC"); - const fetchScores = useCallback(async () => { - setLoading(true); - try { - const response = await scoreApi.getLanguageScores({ verifyStatus: verifyFilter }, page); - setScores(response.content); - setTotalPages(response.totalPages); - } catch (error) { - console.error("Failed to fetch Language scores:", error); - } finally { - setLoading(false); - } - }, [verifyFilter, page]); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ["scores", "language", verifyFilter, page], + queryFn: () => scoreApi.getLanguageScores({ verifyStatus: verifyFilter }, page), + placeholderData: keepPreviousData, + }); - useEffect(() => { - fetchScores(); - }, [fetchScores]); + const updateLanguageMutation = useMutation({ + mutationFn: ({ + id, + status, + reason, + score, + }: { + id: number; + status: VerifyStatus; + reason?: string; + score: LanguageScoreWithUser; + }) => scoreApi.updateLanguageScore(id, status, reason, score), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["scores", "language"] }); + }, + }); + + const scores = data?.content ?? []; + const totalPages = data?.totalPages ?? 1; const handleVerifyStatus = async (id: number, status: VerifyStatus, reason?: string) => { try { const score = scores.find((s) => s.languageTestScoreStatusResponse.id === id); - await scoreApi.updateLanguageScore(id, status, reason, score); - fetchScores(); + if (!score) { + throw new Error("Score data is required"); + } + + await updateLanguageMutation.mutateAsync({ id, status, reason, score }); } catch (error) { console.error("Failed to update Language score:", error); toast.error("성적 상태 업데이트에 실패했습니다"); @@ -74,11 +85,11 @@ export function LanguageScoreTable({ verifyFilter }: Props) { const handleSave = async (score: LanguageScoreWithUser) => { try { - await scoreApi.updateLanguageScore( - score.languageTestScoreStatusResponse.id, - score.languageTestScoreStatusResponse.verifyStatus, - score.languageTestScoreStatusResponse.rejectedReason || undefined, - { + await updateLanguageMutation.mutateAsync({ + id: score.languageTestScoreStatusResponse.id, + status: score.languageTestScoreStatusResponse.verifyStatus, + reason: score.languageTestScoreStatusResponse.rejectedReason || undefined, + score: { ...score, languageTestScoreStatusResponse: { ...score.languageTestScoreStatusResponse, @@ -89,9 +100,8 @@ export function LanguageScoreTable({ verifyFilter }: Props) { }, }, }, - ); + }); setEditingId(null); - fetchScores(); toast.success("어학성적이 수정되었습니다"); } catch (error) { console.error("Failed to update language score:", error); @@ -106,6 +116,9 @@ export function LanguageScoreTable({ verifyFilter }: Props) { return (
+ {isFetching && !isLoading ? ( +
최신 데이터를 불러오는 중...
+ ) : null}
@@ -122,7 +135,7 @@ export function LanguageScoreTable({ verifyFilter }: Props) { - {loading ? ( + {isLoading ? (
diff --git a/apps/admin/src/lib/api/client.ts b/apps/admin/src/lib/api/client.ts index ea475449..93f054b4 100644 --- a/apps/admin/src/lib/api/client.ts +++ b/apps/admin/src/lib/api/client.ts @@ -11,8 +11,14 @@ import { const convertToBearer = (token: string) => `Bearer ${token}`; +const API_SERVER_URL = (import.meta.env.VITE_API_SERVER_URL as string | undefined) || ""; + +if (import.meta.env.DEV && API_SERVER_URL.length === 0) { + console.warn("[admin] VITE_API_SERVER_URL is not configured. API requests will use current origin."); +} + export const axiosInstance: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_SERVER_URL, + baseURL: API_SERVER_URL, withCredentials: true, }); @@ -84,5 +90,5 @@ axiosInstance.interceptors.response.use( ); export const publicAxiosInstance: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_SERVER_URL, + baseURL: API_SERVER_URL, }); diff --git a/apps/admin/src/routes/auth/login.tsx b/apps/admin/src/routes/auth/login.tsx index e722492f..9543b92d 100644 --- a/apps/admin/src/routes/auth/login.tsx +++ b/apps/admin/src/routes/auth/login.tsx @@ -1,3 +1,4 @@ +import { useMutation } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { type FormEvent, useId, useState } from "react"; import { toast } from "sonner"; @@ -18,14 +19,19 @@ function LoginPage() { const passwordInputId = useId(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); + + const signInMutation = useMutation({ + mutationFn: ({ nextEmail, nextPassword }: { nextEmail: string; nextPassword: string }) => + adminSignInApi(nextEmail, nextPassword), + }); + + const isLoading = signInMutation.isPending; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - setIsLoading(true); try { - const response = await adminSignInApi(email, password); + const response = await signInMutation.mutateAsync({ nextEmail: email, nextPassword: password }); const { accessToken, refreshToken } = response.data; saveAccessToken(accessToken); @@ -41,8 +47,6 @@ function LoginPage() { toast.error("로그인 실패", { description: error.response?.data?.message || "로그인에 실패했습니다.", }); - } finally { - setIsLoading(false); } }; diff --git a/apps/admin/src/routes/scores/index.tsx b/apps/admin/src/routes/scores/index.tsx index 419ea776..5b7bc00b 100644 --- a/apps/admin/src/routes/scores/index.tsx +++ b/apps/admin/src/routes/scores/index.tsx @@ -1,26 +1,13 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { Database, Search, SquarePen, UserCircle2 } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; +import { useId, useState } from "react"; import { GpaScoreTable } from "@/components/features/scores/GpaScoreTable"; import { LanguageScoreTable } from "@/components/features/scores/LanguageScoreTable"; import { AdminSidebar } from "@/components/layout/AdminSidebar"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { adminApi } from "@/lib/api/admin"; -import { cn } from "@/lib/utils"; import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken } from "@/lib/utils/localStorage"; import type { VerifyStatus } from "@/types/scores"; -type Region = { code: string; koreanName: string }; -type Country = { code: string; koreanName: string; regionCode: string }; - -const toPrettyJson = (value: unknown) => JSON.stringify(value, null, 2); - export const Route = createFileRoute("/scores/")({ beforeLoad: () => { if (typeof window !== "undefined") { @@ -30,619 +17,67 @@ export const Route = createFileRoute("/scores/")({ } } }, - component: AdminApiDashboardPage, + component: ScoresPage, }); -function AdminApiDashboardPage() { +function ScoresPage() { const [verifyFilter, setVerifyFilter] = useState("PENDING"); - const [searchKeyword, setSearchKeyword] = useState(""); - - const [mentorStatus, setMentorStatus] = useState("PENDING"); - const [mentorPage, setMentorPage] = useState("1"); - const [mentorSize, setMentorSize] = useState("10"); - const [mentorNickname, setMentorNickname] = useState(""); - const [mentorCreatedAt, setMentorCreatedAt] = useState(""); - const [mentorListResult, setMentorListResult] = useState(null); - const [mentorCountResult, setMentorCountResult] = useState(null); - const [historySiteUserId, setHistorySiteUserId] = useState(""); - const [mentorHistoryResult, setMentorHistoryResult] = useState(null); - const [actionMentorId, setActionMentorId] = useState(""); - const [rejectReason, setRejectReason] = useState(""); - const [assignUniversityId, setAssignUniversityId] = useState(""); - const [mentorActionResult, setMentorActionResult] = useState(null); - - const [regions, setRegions] = useState([]); - const [countries, setCountries] = useState([]); - const [regionCode, setRegionCode] = useState(""); - const [regionName, setRegionName] = useState(""); - const [countryCode, setCountryCode] = useState(""); - const [countryName, setCountryName] = useState(""); - const [countryRegionCode, setCountryRegionCode] = useState(""); - const [regionActionResult, setRegionActionResult] = useState(null); - - const topTabs = ["권역/나라", "멘토 지원", "성적 검증", "API 응답"] as const; - - const verifyFilters: Array<{ value: VerifyStatus; label: string }> = [ - { value: "PENDING", label: "대기중" }, - { value: "APPROVED", label: "승인됨" }, - { value: "REJECTED", label: "거절됨" }, - ]; - - const handleError = (message: string, error: unknown) => { - console.error(message, error); - toast.error(message); - }; - - const fetchMentorApplications = async () => { - try { - const res = await adminApi.getMentorApplicationList({ - page: Number.parseInt(mentorPage, 10) || 1, - size: Number.parseInt(mentorSize, 10) || 10, - mentorApplicationStatus: mentorStatus || undefined, - nickname: mentorNickname || undefined, - createdAt: mentorCreatedAt || undefined, - }); - setMentorListResult(res); - toast.success("멘토 지원 목록 조회 완료"); - } catch (error) { - handleError("멘토 지원 목록 조회 실패", error); - } - }; - - const fetchMentorCount = async () => { - try { - const res = await adminApi.getCountMentorApplicationByStatus(); - setMentorCountResult(res); - toast.success("멘토 상태 카운트 조회 완료"); - } catch (error) { - handleError("멘토 상태 카운트 조회 실패", error); - } - }; - - const fetchMentorHistory = async () => { - if (!historySiteUserId) { - toast.error("site_user_id를 입력해주세요."); - return; - } - - try { - const res = await adminApi.getMentorApplicationHistoryList(historySiteUserId); - setMentorHistoryResult(res); - toast.success("멘토 지원 히스토리 조회 완료"); - } catch (error) { - handleError("멘토 지원 히스토리 조회 실패", error); - } - }; - - const approveMentor = async () => { - if (!actionMentorId) { - toast.error("mentorApplicationId를 입력해주세요."); - return; - } - - try { - const res = await adminApi.postApproveMentorApplication(actionMentorId); - setMentorActionResult(res); - toast.success("멘토 지원 승인 완료"); - } catch (error) { - handleError("멘토 지원 승인 실패", error); - } - }; - - const rejectMentor = async () => { - if (!actionMentorId || !rejectReason) { - toast.error("mentorApplicationId와 거절 사유를 입력해주세요."); - return; - } - - try { - const res = await adminApi.postRejectMentorApplication(actionMentorId, rejectReason); - setMentorActionResult(res); - toast.success("멘토 지원 거절 완료"); - } catch (error) { - handleError("멘토 지원 거절 실패", error); - } - }; - - const assignUniversity = async () => { - if (!actionMentorId || !assignUniversityId) { - toast.error("mentorApplicationId와 universityId를 입력해주세요."); - return; - } - - try { - const res = await adminApi.postMappingMentorapplicationUniversity( - actionMentorId, - Number.parseInt(assignUniversityId, 10), - ); - setMentorActionResult(res); - toast.success("대학 매핑 완료"); - } catch (error) { - handleError("대학 매핑 실패", error); - } - }; - - const fetchRegions = async () => { - try { - const res = await adminApi.get권역조회(); - const nextRegions = Array.isArray(res) ? (res as Region[]) : []; - setRegions(nextRegions); - setRegionActionResult(res); - toast.success("권역 조회 완료"); - } catch (error) { - handleError("권역 조회 실패", error); - } - }; - - const createRegion = async () => { - if (!regionName) { - toast.error("권역명을 입력해주세요."); - return; - } - - try { - const res = await adminApi.post권역생성({ code: regionCode || undefined, koreanName: regionName }); - setRegionActionResult(res); - toast.success("권역 생성 완료"); - await fetchRegions(); - } catch (error) { - handleError("권역 생성 실패", error); - } - }; - - const updateRegion = async () => { - if (!regionCode || !regionName) { - toast.error("수정할 권역 코드와 이름을 입력해주세요."); - return; - } - - try { - const res = await adminApi.put권역수정(regionCode, { code: regionCode, koreanName: regionName }); - setRegionActionResult(res); - toast.success("권역 수정 완료"); - await fetchRegions(); - } catch (error) { - handleError("권역 수정 실패", error); - } - }; - - const deleteRegion = async () => { - if (!regionCode) { - toast.error("삭제할 권역 코드를 입력해주세요."); - return; - } - - try { - const res = await adminApi.delete권역삭제(regionCode); - setRegionActionResult(res); - toast.success("권역 삭제 완료"); - await fetchRegions(); - } catch (error) { - handleError("권역 삭제 실패", error); - } - }; - - const fetchCountries = async () => { - try { - const res = await adminApi.get지역조회(); - const nextCountries = Array.isArray(res) ? (res as Country[]) : []; - setCountries(nextCountries); - setRegionActionResult(res); - toast.success("지역 조회 완료"); - } catch (error) { - handleError("지역 조회 실패", error); - } - }; - - const createCountry = async () => { - if (!countryName || !countryRegionCode) { - toast.error("지역명과 권역 코드를 입력해주세요."); - return; - } - - try { - const res = await adminApi.post지역생성({ - code: countryCode || undefined, - koreanName: countryName, - regionCode: countryRegionCode, - }); - setRegionActionResult(res); - toast.success("지역 생성 완료"); - await fetchCountries(); - } catch (error) { - handleError("지역 생성 실패", error); - } - }; - - const updateCountry = async () => { - if (!countryCode || !countryName || !countryRegionCode) { - toast.error("수정할 지역 코드/이름/권역 코드를 입력해주세요."); - return; - } - - try { - const res = await adminApi.put지역수정(countryCode, { - code: countryCode, - koreanName: countryName, - regionCode: countryRegionCode, - }); - setRegionActionResult(res); - toast.success("지역 수정 완료"); - await fetchCountries(); - } catch (error) { - handleError("지역 수정 실패", error); - } - }; - - const deleteCountry = async () => { - if (!countryCode) { - toast.error("삭제할 지역 코드를 입력해주세요."); - return; - } - - try { - const res = await adminApi.delete지역삭제(countryCode); - setRegionActionResult(res); - toast.success("지역 삭제 완료"); - await fetchCountries(); - } catch (error) { - handleError("지역 삭제 실패", error); - } - }; + const verifyFilterId = useId(); return (
-
-
-
- {topTabs.map((tab) => ( - - ))} +
+
+

성적 관리

+

+ 원본 어드민 플로우를 기준으로 성적 검수 데이터를 관리합니다. +

+ +
+ +
-
-

Admin API 통합 대시보드

- -
- setSearchKeyword(event.target.value)} - placeholder="필터 키워드(시각적 보조용)" - className="h-9 border-k-100 bg-bg-50 pr-10 typo-regular-4 text-k-700" - /> - -
- - +
+ + + + GPA 성적 + + + 어학성적 + + + + + + + + + + +
- - - - - 멘토 지원 API (6) - - - 권역/지역 API (8) - - - 성적 API (4) - - - - -
-
-

- - 멘토 지원 목록/카운트/히스토리 조회 -

-
- setMentorStatus(e.target.value)} - placeholder="status" - /> - setMentorPage(e.target.value)} placeholder="page" /> - setMentorSize(e.target.value)} placeholder="size" /> - setMentorNickname(e.target.value)} - placeholder="nickname" - /> -
- setMentorCreatedAt(e.target.value)} - placeholder="createdAt (YYYY-MM-DD)" - /> -
- - -
-
- setHistorySiteUserId(e.target.value)} - placeholder="site_user_id" - /> - -
-
- -
-

멘토 지원 승인/거절/대학 매핑

- setActionMentorId(e.target.value)} - placeholder="mentorApplicationId" - /> -
- - -
-