Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 43 additions & 30 deletions apps/admin/src/components/features/scores/GpaScoreTable.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<GpaScoreWithUser[]>([]);
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingGpa, setEditingGpa] = useState<number>(0);
const [editingGpaCriteria, setEditingGpaCriteria] = useState<number>(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("성적 상태 업데이트에 실패했습니다");
Expand All @@ -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,
Expand All @@ -74,9 +85,8 @@ export function GpaScoreTable({ verifyFilter }: Props) {
},
},
},
);
});
setEditingId(null);
fetchScores();
toast.success("GPA가 수정되었습니다");
} catch (error) {
console.error("Failed to update GPA:", error);
Expand All @@ -91,6 +101,9 @@ export function GpaScoreTable({ verifyFilter }: Props) {

return (
<div className="rounded-lg border border-k-100 bg-k-0">
{isFetching && !isLoading ? (
<div className="border-b border-k-100 px-4 py-2 typo-regular-4 text-k-500">최신 데이터를 불러오는 중...</div>
) : null}
<div className="overflow-x-auto">
<Table>
<TableHeader>
Expand All @@ -107,7 +120,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
{isLoading ? (
<TableRow>
<TableCell colSpan={9} className="text-center">
<div className="flex items-center justify-center">
Expand Down
73 changes: 43 additions & 30 deletions apps/admin/src/components/features/scores/LanguageScoreTable.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" },
Expand All @@ -30,36 +31,46 @@ const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [
];

export function LanguageScoreTable({ verifyFilter }: Props) {
const [scores, setScores] = useState<LanguageScoreWithUser[]>([]);
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingScore, setEditingScore] = useState<string>("");
const [editingType, setEditingType] = useState<LanguageTestType>("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("성적 상태 업데이트에 실패했습니다");
Expand All @@ -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,
Expand All @@ -89,9 +100,8 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
},
},
},
);
});
setEditingId(null);
fetchScores();
toast.success("어학성적이 수정되었습니다");
} catch (error) {
console.error("Failed to update language score:", error);
Expand All @@ -106,6 +116,9 @@ export function LanguageScoreTable({ verifyFilter }: Props) {

return (
<div className="rounded-lg border border-k-100 bg-k-0">
{isFetching && !isLoading ? (
<div className="border-b border-k-100 px-4 py-2 typo-regular-4 text-k-500">최신 데이터를 불러오는 중...</div>
) : null}
<div className="overflow-x-auto">
<Table>
<TableHeader>
Expand All @@ -122,7 +135,7 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
{isLoading ? (
<TableRow>
<TableCell colSpan={9} className="text-center">
<div className="flex items-center justify-center">
Expand Down
18 changes: 16 additions & 2 deletions apps/admin/src/components/layout/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@ interface AdminLayoutProps {

export function AdminLayout({ children }: AdminLayoutProps) {
return (
<div className="min-h-screen bg-bg-50 text-k-800">
<main className="p-6">{children}</main>
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_#eef2ff_0%,_#fafafa_48%,_#f5f5f5_100%)] text-k-800">
<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">
<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)]">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-primary typo-sb-9">
SC
</div>
<div className="leading-tight">
<p className="typo-medium-4 text-k-500">Solid Connection</p>
<h1 className="typo-sb-7 text-k-900">Admin</h1>
</div>
</div>
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
</header>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions apps/admin/src/components/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
8 changes: 3 additions & 5 deletions apps/admin/src/components/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";

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

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

const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
<div ref={ref} className={cn("leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";

const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
({ className, ...props }, ref) => <div ref={ref} className={cn("text-sm text-k-500", className)} {...props} />,
);
CardDescription.displayName = "CardDescription";

Expand Down
10 changes: 8 additions & 2 deletions apps/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ import {

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

const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();

if (!API_SERVER_URL) {
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
}

export const axiosInstance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_SERVER_URL,
baseURL: API_SERVER_URL,
withCredentials: true,
});

Expand Down Expand Up @@ -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,
});
21 changes: 21 additions & 0 deletions apps/admin/src/lib/query/queryClient.ts
Original file line number Diff line number Diff line change
@@ -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;
},
},
},
});
Loading