Skip to content

Commit 4133381

Browse files
authored
feat: 어드민 로그인 레이아웃 분리와 성적 관리 UX 정렬 (#459)
* feat: 어드민 로그인 레이아웃 분리와 성적 관리 UX 정렬 * chore: 어드민 React Query 공통 설정 추가 * refactor: 어드민 로그인과 성적 검수 API를 React Query로 전환 * fix: 어드민 VITE 환경변수 전달 및 API URL 필수화
1 parent 9bbb0cf commit 4133381

16 files changed

Lines changed: 267 additions & 719 deletions

File tree

apps/admin/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
"@radix-ui/react-tabs": "^1.1.13",
2323
"@tailwindcss/vite": "^4.0.6",
2424
"@tanstack/react-devtools": "^0.7.0",
25-
"@tanstack/react-router": "^1.132.0",
26-
"@tanstack/react-router-devtools": "^1.132.0",
27-
"@tanstack/react-router-ssr-query": "^1.131.7",
25+
"@tanstack/react-router": "^1.132.0",
26+
"@tanstack/react-router-devtools": "^1.132.0",
27+
"@tanstack/react-query": "^5.84.1",
28+
"@tanstack/react-router-ssr-query": "^1.131.7",
2829
"@tanstack/react-start": "^1.132.0",
2930
"@tanstack/router-plugin": "^1.132.0",
3031
"axios": "^1.6.7",

apps/admin/src/components/features/scores/GpaScoreTable.tsx

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
12
import { format } from "date-fns";
2-
import { useCallback, useEffect, useState } from "react";
3+
import { useState } from "react";
34
import { toast } from "sonner";
45
import { Button } from "@/components/ui/button";
56
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -12,39 +13,49 @@ interface Props {
1213
verifyFilter: VerifyStatus;
1314
}
1415

15-
const S3_BASE_URL = import.meta.env.VITE_S3_BASE_URL;
16+
const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || "";
1617

1718
export function GpaScoreTable({ verifyFilter }: Props) {
18-
const [scores, setScores] = useState<GpaScoreWithUser[]>([]);
19+
const queryClient = useQueryClient();
1920
const [page, setPage] = useState(1);
20-
const [totalPages, setTotalPages] = useState(1);
21-
const [loading, setLoading] = useState(false);
2221
const [editingId, setEditingId] = useState<number | null>(null);
2322
const [editingGpa, setEditingGpa] = useState<number>(0);
2423
const [editingGpaCriteria, setEditingGpaCriteria] = useState<number>(0);
2524

26-
const fetchScores = useCallback(async () => {
27-
setLoading(true);
28-
try {
29-
const response = await scoreApi.getGpaScores({ verifyStatus: verifyFilter }, page);
30-
setScores(response.content);
31-
setTotalPages(response.totalPages);
32-
} catch (error) {
33-
console.error("Failed to fetch GPA scores:", error);
34-
} finally {
35-
setLoading(false);
36-
}
37-
}, [verifyFilter, page]);
25+
const { data, isLoading, isFetching } = useQuery({
26+
queryKey: ["scores", "gpa", verifyFilter, page],
27+
queryFn: () => scoreApi.getGpaScores({ verifyStatus: verifyFilter }, page),
28+
placeholderData: keepPreviousData,
29+
});
3830

39-
useEffect(() => {
40-
fetchScores();
41-
}, [fetchScores]);
31+
const updateGpaMutation = useMutation({
32+
mutationFn: ({
33+
id,
34+
status,
35+
reason,
36+
score,
37+
}: {
38+
id: number;
39+
status: VerifyStatus;
40+
reason?: string;
41+
score: GpaScoreWithUser;
42+
}) => scoreApi.updateGpaScore(id, status, reason, score),
43+
onSuccess: async () => {
44+
await queryClient.invalidateQueries({ queryKey: ["scores", "gpa"] });
45+
},
46+
});
47+
48+
const scores = data?.content ?? [];
49+
const totalPages = data?.totalPages ?? 1;
4250

4351
const handleVerifyStatus = async (id: number, status: VerifyStatus, reason?: string) => {
4452
try {
4553
const score = scores.find((s) => s.gpaScoreStatusResponse.id === id);
46-
await scoreApi.updateGpaScore(id, status, reason, score);
47-
fetchScores();
54+
if (!score) {
55+
throw new Error("Score data is required");
56+
}
57+
58+
await updateGpaMutation.mutateAsync({ id, status, reason, score });
4859
} catch (error) {
4960
console.error("Failed to update GPA score:", error);
5061
toast.error("성적 상태 업데이트에 실패했습니다");
@@ -59,11 +70,11 @@ export function GpaScoreTable({ verifyFilter }: Props) {
5970

6071
const handleSave = async (score: GpaScoreWithUser) => {
6172
try {
62-
await scoreApi.updateGpaScore(
63-
score.gpaScoreStatusResponse.id,
64-
score.gpaScoreStatusResponse.verifyStatus,
65-
score.gpaScoreStatusResponse.rejectedReason || undefined,
66-
{
73+
await updateGpaMutation.mutateAsync({
74+
id: score.gpaScoreStatusResponse.id,
75+
status: score.gpaScoreStatusResponse.verifyStatus,
76+
reason: score.gpaScoreStatusResponse.rejectedReason || undefined,
77+
score: {
6778
...score,
6879
gpaScoreStatusResponse: {
6980
...score.gpaScoreStatusResponse,
@@ -74,9 +85,8 @@ export function GpaScoreTable({ verifyFilter }: Props) {
7485
},
7586
},
7687
},
77-
);
88+
});
7889
setEditingId(null);
79-
fetchScores();
8090
toast.success("GPA가 수정되었습니다");
8191
} catch (error) {
8292
console.error("Failed to update GPA:", error);
@@ -91,6 +101,9 @@ export function GpaScoreTable({ verifyFilter }: Props) {
91101

92102
return (
93103
<div className="rounded-lg border border-k-100 bg-k-0">
104+
{isFetching && !isLoading ? (
105+
<div className="border-b border-k-100 px-4 py-2 typo-regular-4 text-k-500">최신 데이터를 불러오는 중...</div>
106+
) : null}
94107
<div className="overflow-x-auto">
95108
<Table>
96109
<TableHeader>
@@ -107,7 +120,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
107120
</TableRow>
108121
</TableHeader>
109122
<TableBody>
110-
{loading ? (
123+
{isLoading ? (
111124
<TableRow>
112125
<TableCell colSpan={9} className="text-center">
113126
<div className="flex items-center justify-center">

apps/admin/src/components/features/scores/LanguageScoreTable.tsx

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
12
import { format } from "date-fns";
2-
import { useCallback, useEffect, useState } from "react";
3+
import { useState } from "react";
34
import { toast } from "sonner";
45
import { Button } from "@/components/ui/button";
56
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -12,7 +13,7 @@ interface Props {
1213
verifyFilter: VerifyStatus;
1314
}
1415

15-
const S3_BASE_URL = import.meta.env.VITE_S3_BASE_URL;
16+
const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || "";
1617

1718
const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [
1819
{ value: "TOEIC", label: "TOEIC" },
@@ -30,36 +31,46 @@ const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [
3031
];
3132

3233
export function LanguageScoreTable({ verifyFilter }: Props) {
33-
const [scores, setScores] = useState<LanguageScoreWithUser[]>([]);
34+
const queryClient = useQueryClient();
3435
const [page, setPage] = useState(1);
35-
const [totalPages, setTotalPages] = useState(1);
36-
const [loading, setLoading] = useState(false);
3736
const [editingId, setEditingId] = useState<number | null>(null);
3837
const [editingScore, setEditingScore] = useState<string>("");
3938
const [editingType, setEditingType] = useState<LanguageTestType>("TOEIC");
4039

41-
const fetchScores = useCallback(async () => {
42-
setLoading(true);
43-
try {
44-
const response = await scoreApi.getLanguageScores({ verifyStatus: verifyFilter }, page);
45-
setScores(response.content);
46-
setTotalPages(response.totalPages);
47-
} catch (error) {
48-
console.error("Failed to fetch Language scores:", error);
49-
} finally {
50-
setLoading(false);
51-
}
52-
}, [verifyFilter, page]);
40+
const { data, isLoading, isFetching } = useQuery({
41+
queryKey: ["scores", "language", verifyFilter, page],
42+
queryFn: () => scoreApi.getLanguageScores({ verifyStatus: verifyFilter }, page),
43+
placeholderData: keepPreviousData,
44+
});
5345

54-
useEffect(() => {
55-
fetchScores();
56-
}, [fetchScores]);
46+
const updateLanguageMutation = useMutation({
47+
mutationFn: ({
48+
id,
49+
status,
50+
reason,
51+
score,
52+
}: {
53+
id: number;
54+
status: VerifyStatus;
55+
reason?: string;
56+
score: LanguageScoreWithUser;
57+
}) => scoreApi.updateLanguageScore(id, status, reason, score),
58+
onSuccess: async () => {
59+
await queryClient.invalidateQueries({ queryKey: ["scores", "language"] });
60+
},
61+
});
62+
63+
const scores = data?.content ?? [];
64+
const totalPages = data?.totalPages ?? 1;
5765

5866
const handleVerifyStatus = async (id: number, status: VerifyStatus, reason?: string) => {
5967
try {
6068
const score = scores.find((s) => s.languageTestScoreStatusResponse.id === id);
61-
await scoreApi.updateLanguageScore(id, status, reason, score);
62-
fetchScores();
69+
if (!score) {
70+
throw new Error("Score data is required");
71+
}
72+
73+
await updateLanguageMutation.mutateAsync({ id, status, reason, score });
6374
} catch (error) {
6475
console.error("Failed to update Language score:", error);
6576
toast.error("성적 상태 업데이트에 실패했습니다");
@@ -74,11 +85,11 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
7485

7586
const handleSave = async (score: LanguageScoreWithUser) => {
7687
try {
77-
await scoreApi.updateLanguageScore(
78-
score.languageTestScoreStatusResponse.id,
79-
score.languageTestScoreStatusResponse.verifyStatus,
80-
score.languageTestScoreStatusResponse.rejectedReason || undefined,
81-
{
88+
await updateLanguageMutation.mutateAsync({
89+
id: score.languageTestScoreStatusResponse.id,
90+
status: score.languageTestScoreStatusResponse.verifyStatus,
91+
reason: score.languageTestScoreStatusResponse.rejectedReason || undefined,
92+
score: {
8293
...score,
8394
languageTestScoreStatusResponse: {
8495
...score.languageTestScoreStatusResponse,
@@ -89,9 +100,8 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
89100
},
90101
},
91102
},
92-
);
103+
});
93104
setEditingId(null);
94-
fetchScores();
95105
toast.success("어학성적이 수정되었습니다");
96106
} catch (error) {
97107
console.error("Failed to update language score:", error);
@@ -106,6 +116,9 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
106116

107117
return (
108118
<div className="rounded-lg border border-k-100 bg-k-0">
119+
{isFetching && !isLoading ? (
120+
<div className="border-b border-k-100 px-4 py-2 typo-regular-4 text-k-500">최신 데이터를 불러오는 중...</div>
121+
) : null}
109122
<div className="overflow-x-auto">
110123
<Table>
111124
<TableHeader>
@@ -122,7 +135,7 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
122135
</TableRow>
123136
</TableHeader>
124137
<TableBody>
125-
{loading ? (
138+
{isLoading ? (
126139
<TableRow>
127140
<TableCell colSpan={9} className="text-center">
128141
<div className="flex items-center justify-center">

apps/admin/src/components/layout/AdminLayout.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,22 @@ interface AdminLayoutProps {
44

55
export function AdminLayout({ children }: AdminLayoutProps) {
66
return (
7-
<div className="min-h-screen bg-bg-50 text-k-800">
8-
<main className="p-6">{children}</main>
7+
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_#eef2ff_0%,_#fafafa_48%,_#f5f5f5_100%)] text-k-800">
8+
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col px-3 py-4 sm:px-4 sm:py-6 lg:px-8">
9+
<header className="mb-4 flex items-center justify-between rounded-2xl border border-k-100 bg-k-0 px-4 py-3 shadow-[0_10px_30px_-24px_rgba(26,31,39,0.45)]">
10+
<div className="flex items-center gap-3">
11+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-primary typo-sb-9">
12+
SC
13+
</div>
14+
<div className="leading-tight">
15+
<p className="typo-medium-4 text-k-500">Solid Connection</p>
16+
<h1 className="typo-sb-7 text-k-900">Admin</h1>
17+
</div>
18+
</div>
19+
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
20+
</header>
21+
<main className="flex-1">{children}</main>
22+
</div>
923
</div>
1024
);
1125
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { QueryClientProvider } from "@tanstack/react-query";
2+
import { type ReactNode, useState } from "react";
3+
import { createQueryClient } from "@/lib/query/queryClient";
4+
5+
interface QueryProviderProps {
6+
children: ReactNode;
7+
}
8+
9+
export function QueryProvider({ children }: QueryProviderProps) {
10+
const [queryClient] = useState(createQueryClient);
11+
12+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
13+
}

apps/admin/src/components/ui/card.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as React from "react";
33
import { cn } from "@/lib/utils";
44

55
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
6-
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
6+
<div ref={ref} className={cn("rounded-xl border border-k-100 bg-k-0 text-k-800 shadow", className)} {...props} />
77
));
88
Card.displayName = "Card";
99

@@ -16,15 +16,13 @@ CardHeader.displayName = "CardHeader";
1616

1717
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
1818
({ className, ...props }, ref) => (
19-
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
19+
<div ref={ref} className={cn("leading-none tracking-tight", className)} {...props} />
2020
),
2121
);
2222
CardTitle.displayName = "CardTitle";
2323

2424
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
25-
({ className, ...props }, ref) => (
26-
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
27-
),
25+
({ className, ...props }, ref) => <div ref={ref} className={cn("text-sm text-k-500", className)} {...props} />,
2826
);
2927
CardDescription.displayName = "CardDescription";
3028

apps/admin/src/lib/api/client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ import {
1111

1212
const convertToBearer = (token: string) => `Bearer ${token}`;
1313

14+
const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();
15+
16+
if (!API_SERVER_URL) {
17+
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
18+
}
19+
1420
export const axiosInstance: AxiosInstance = axios.create({
15-
baseURL: import.meta.env.VITE_API_SERVER_URL,
21+
baseURL: API_SERVER_URL,
1622
withCredentials: true,
1723
});
1824

@@ -84,5 +90,5 @@ axiosInstance.interceptors.response.use(
8490
);
8591

8692
export const publicAxiosInstance: AxiosInstance = axios.create({
87-
baseURL: import.meta.env.VITE_API_SERVER_URL,
93+
baseURL: API_SERVER_URL,
8894
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { QueryClient } from "@tanstack/react-query";
2+
import type { AxiosError } from "axios";
3+
4+
export const createQueryClient = () =>
5+
new QueryClient({
6+
defaultOptions: {
7+
queries: {
8+
staleTime: 60 * 1000,
9+
gcTime: 10 * 60 * 1000,
10+
refetchOnWindowFocus: false,
11+
retry: (failureCount, error) => {
12+
const status = (error as AxiosError | undefined)?.response?.status;
13+
if (status === 401 || status === 403) {
14+
return false;
15+
}
16+
17+
return failureCount < 1;
18+
},
19+
},
20+
},
21+
});

0 commit comments

Comments
 (0)