feat: 3-mode inspection with tabbed UI + batch upload
- Add batch inspection backend (multipart upload, SSE streaming, MongoDB) - Add tabbed UI (single page / site crawling / batch upload) on home and history pages - Add batch inspection progress, result pages with 2-panel layout - Rename "사이트 전체" to "사이트 크롤링" across codebase - Add python-multipart dependency for file upload - Consolidate nginx SSE location for all inspection types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
481
frontend/src/app/batch-inspections/[id]/page.tsx
Normal file
481
frontend/src/app/batch-inspections/[id]/page.tsx
Normal file
@ -0,0 +1,481 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useCallback } from "react";
|
||||
import { useBatchInspectionResult, useInspectionResult, useInspectionIssues } from "@/lib/queries";
|
||||
import { BatchUrlList } from "@/components/batch-inspection/BatchUrlList";
|
||||
import { OverallScoreGauge } from "@/components/dashboard/OverallScoreGauge";
|
||||
import { CategoryScoreCard } from "@/components/dashboard/CategoryScoreCard";
|
||||
import { IssueSummaryBar } from "@/components/dashboard/IssueSummaryBar";
|
||||
import { InspectionMeta } from "@/components/dashboard/InspectionMeta";
|
||||
import { FilterBar } from "@/components/issues/FilterBar";
|
||||
import { IssueList } from "@/components/issues/IssueList";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { ErrorState } from "@/components/common/ErrorState";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CATEGORY_LABELS, CATEGORY_KEYS, getScoreTailwindColor } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Clock, Menu, X, ArrowLeft, FileText } from "lucide-react";
|
||||
import type { CategoryKey } from "@/types/inspection";
|
||||
|
||||
export default function BatchInspectionResultPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const [selectedPageUrl, setSelectedPageUrl] = useState<string | null>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: batchResult,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useBatchInspectionResult(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<LoadingSpinner message="배치 검사 결과를 불러오는 중..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !batchResult) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<ErrorState
|
||||
message={
|
||||
error instanceof ApiError
|
||||
? error.detail
|
||||
: "배치 검사 결과를 불러올 수 없습니다"
|
||||
}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 페이지의 inspection_id 찾기
|
||||
const selectedPage = selectedPageUrl
|
||||
? batchResult.discovered_pages.find((p) => p.url === selectedPageUrl)
|
||||
: null;
|
||||
|
||||
const handleSelectPage = (url: string | null) => {
|
||||
setSelectedPageUrl(url);
|
||||
// 모바일: 사이드바 닫기
|
||||
setIsSidebarOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)]">
|
||||
{/* 배치 이름 헤더 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">{batchResult.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 모바일 사이드바 토글 */}
|
||||
<div className="lg:hidden flex items-center gap-2 px-4 py-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<Menu className="h-4 w-4" />
|
||||
)}
|
||||
URL 목록
|
||||
</Button>
|
||||
{selectedPageUrl && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{selectedPageUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full relative">
|
||||
{/* 왼쪽 사이드바: URL 목록 */}
|
||||
<aside
|
||||
className={`
|
||||
w-72 border-r bg-card flex-shrink-0 h-full
|
||||
lg:relative lg:translate-x-0 lg:block
|
||||
absolute z-20 top-0 left-0
|
||||
transition-transform duration-200
|
||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
|
||||
`}
|
||||
>
|
||||
<BatchUrlList
|
||||
pages={batchResult.discovered_pages}
|
||||
selectedUrl={selectedPageUrl}
|
||||
onSelectPage={handleSelectPage}
|
||||
aggregateScores={batchResult.aggregate_scores}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-10 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 오른쪽 패널: 결과 표시 */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{selectedPageUrl === null ? (
|
||||
// 전체 집계 보기
|
||||
batchResult.aggregate_scores ? (
|
||||
<BatchAggregatePanel
|
||||
aggregateScores={batchResult.aggregate_scores}
|
||||
name={batchResult.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center py-12 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3" />
|
||||
<p>아직 집계 결과가 없습니다</p>
|
||||
</div>
|
||||
)
|
||||
) : selectedPage?.inspection_id ? (
|
||||
// 개별 페이지 결과 (대시보드 재사용)
|
||||
<PageDashboard
|
||||
inspectionId={selectedPage.inspection_id}
|
||||
pageUrl={selectedPageUrl}
|
||||
/>
|
||||
) : (
|
||||
// 검사 대기 중인 페이지
|
||||
<div className="flex flex-col items-center py-12 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3" />
|
||||
<p className="text-lg font-medium">검사 대기 중</p>
|
||||
<p className="text-sm mt-1">
|
||||
이 페이지는 아직 검사가 완료되지 않았습니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 검사 전체 집계 패널.
|
||||
* AggregateScorePanel을 배치 검사용으로 래핑한다 (rootUrl 대신 name 표시).
|
||||
*/
|
||||
function BatchAggregatePanel({
|
||||
aggregateScores,
|
||||
name,
|
||||
}: {
|
||||
aggregateScores: NonNullable<
|
||||
import("@/types/batch-inspection").BatchInspectionResult["aggregate_scores"]
|
||||
>;
|
||||
name: string;
|
||||
}) {
|
||||
// AggregateScorePanel은 rootUrl을 받지만, 배치에서는 name을 표시하도록
|
||||
// 직접 구현하여 "배치 검사 결과"로 제목 변경
|
||||
const categoryItems = [
|
||||
{
|
||||
key: "html_css" as const,
|
||||
label: CATEGORY_LABELS.html_css,
|
||||
score: aggregateScores.html_css,
|
||||
},
|
||||
{
|
||||
key: "accessibility" as const,
|
||||
label: CATEGORY_LABELS.accessibility,
|
||||
score: aggregateScores.accessibility,
|
||||
},
|
||||
{
|
||||
key: "seo" as const,
|
||||
label: CATEGORY_LABELS.seo,
|
||||
score: aggregateScores.seo,
|
||||
},
|
||||
{
|
||||
key: "performance_security" as const,
|
||||
label: CATEGORY_LABELS.performance_security,
|
||||
score: aggregateScores.performance_security,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 상단 배치 이름 및 검사 요약 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-1">배치 검사 결과</h2>
|
||||
<p className="text-sm text-muted-foreground">{name}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
검사 완료: {aggregateScores.pages_inspected}/
|
||||
{aggregateScores.pages_total} 페이지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 종합 점수 게이지 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">배치 종합 점수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<OverallScoreGauge
|
||||
score={aggregateScores.overall_score}
|
||||
grade={aggregateScores.grade as import("@/types/inspection").Grade}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카테고리별 평균 점수 카드 (4개) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
{categoryItems.map((item) => (
|
||||
<Card key={item.key}>
|
||||
<CardContent className="pt-5 pb-4 px-5 text-center">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{item.label}
|
||||
</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"text-3xl font-bold mb-1",
|
||||
getScoreTailwindColor(item.score)
|
||||
)}
|
||||
>
|
||||
{item.score}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
페이지 평균 점수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 총 이슈 수 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
배치 전체 이슈
|
||||
</span>
|
||||
<span className="text-lg font-bold">
|
||||
총 {aggregateScores.total_issues}건
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 페이지 대시보드 컴포넌트.
|
||||
* 카테고리 클릭 시 이슈 목록을 인라인으로 표시하여 사이드바를 유지한다.
|
||||
*/
|
||||
function PageDashboard({
|
||||
inspectionId,
|
||||
pageUrl,
|
||||
}: {
|
||||
inspectionId: string;
|
||||
pageUrl: string;
|
||||
}) {
|
||||
// 이슈 보기 모드 상태
|
||||
const [issueView, setIssueView] = useState<{
|
||||
showing: boolean;
|
||||
initialCategory: string;
|
||||
}>({ showing: false, initialCategory: "all" });
|
||||
|
||||
const {
|
||||
data: result,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useInspectionResult(inspectionId);
|
||||
|
||||
const handleCategoryClick = useCallback(
|
||||
(category: CategoryKey) => {
|
||||
setIssueView({ showing: true, initialCategory: category });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleViewIssues = useCallback(() => {
|
||||
setIssueView({ showing: true, initialCategory: "all" });
|
||||
}, []);
|
||||
|
||||
const handleBackToDashboard = useCallback(() => {
|
||||
setIssueView({ showing: false, initialCategory: "all" });
|
||||
}, []);
|
||||
|
||||
// inspectionId 변경 시 이슈 뷰 초기화
|
||||
const [prevId, setPrevId] = useState(inspectionId);
|
||||
if (inspectionId !== prevId) {
|
||||
setPrevId(inspectionId);
|
||||
setIssueView({ showing: false, initialCategory: "all" });
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="페이지 검사 결과를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (isError || !result) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={
|
||||
error instanceof ApiError
|
||||
? error.detail
|
||||
: "검사 결과를 불러올 수 없습니다"
|
||||
}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 이슈 목록 인라인 보기
|
||||
if (issueView.showing) {
|
||||
return (
|
||||
<InlineIssueView
|
||||
inspectionId={inspectionId}
|
||||
initialCategory={issueView.initialCategory}
|
||||
onBack={handleBackToDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 페이지 URL 표시 */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold">페이지 검사 결과</h2>
|
||||
<a
|
||||
href={pageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{pageUrl}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 종합 점수 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">종합 점수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<OverallScoreGauge
|
||||
score={result.overall_score}
|
||||
grade={result.grade}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카테고리별 점수 카드 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
{CATEGORY_KEYS.map((key) => {
|
||||
const cat = result.categories[key];
|
||||
return (
|
||||
<CategoryScoreCard
|
||||
key={key}
|
||||
categoryName={CATEGORY_LABELS[key]}
|
||||
score={cat.score}
|
||||
grade={cat.grade}
|
||||
issueCount={cat.total_issues}
|
||||
onClick={() => handleCategoryClick(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검사 메타 정보 */}
|
||||
<div className="mb-6">
|
||||
<InspectionMeta
|
||||
url={result.url}
|
||||
createdAt={result.created_at}
|
||||
durationSeconds={result.duration_seconds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이슈 상세 링크 */}
|
||||
<div className="mb-6">
|
||||
<Button onClick={handleViewIssues} variant="default">
|
||||
이슈 상세 보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 이슈 요약 바 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<IssueSummaryBar
|
||||
critical={result.summary.critical}
|
||||
major={result.summary.major}
|
||||
minor={result.summary.minor}
|
||||
info={result.summary.info}
|
||||
total={result.summary.total_issues}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈 목록 인라인 뷰.
|
||||
* 배치 검사 결과 페이지 내에서 이슈를 표시하여 사이드바를 유지한다.
|
||||
*/
|
||||
function InlineIssueView({
|
||||
inspectionId,
|
||||
initialCategory,
|
||||
onBack,
|
||||
}: {
|
||||
inspectionId: string;
|
||||
initialCategory: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [selectedCategory, setSelectedCategory] = useState(initialCategory);
|
||||
const [selectedSeverity, setSelectedSeverity] = useState("all");
|
||||
|
||||
const { data, isLoading, isError, refetch } = useInspectionIssues(
|
||||
inspectionId,
|
||||
selectedCategory === "all" ? undefined : selectedCategory,
|
||||
selectedSeverity === "all" ? undefined : selectedSeverity
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 뒤로가기 */}
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
검사 결과로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold mb-4">상세 이슈 목록</h2>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="mb-6">
|
||||
<FilterBar
|
||||
selectedCategory={selectedCategory}
|
||||
selectedSeverity={selectedSeverity}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
onSeverityChange={setSelectedSeverity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이슈 목록 */}
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="이슈 목록을 불러올 수 없습니다"
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<IssueList
|
||||
issues={data?.issues}
|
||||
isLoading={isLoading}
|
||||
total={data?.total || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/app/batch-inspections/[id]/progress/page.tsx
Normal file
33
frontend/src/app/batch-inspections/[id]/progress/page.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useBatchInspectionResult } from "@/lib/queries";
|
||||
import { BatchProgress } from "@/components/batch-inspection/BatchProgress";
|
||||
|
||||
export default function BatchInspectionProgressPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
// 이미 완료된 검사인 경우 결과 페이지로 리다이렉트
|
||||
const { data: batchResult } = useBatchInspectionResult(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (batchResult?.status === "completed" || batchResult?.status === "error") {
|
||||
router.replace(`/batch-inspections/${id}`);
|
||||
}
|
||||
}, [batchResult?.status, id, router]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">
|
||||
배치 검사
|
||||
</h1>
|
||||
<BatchProgress batchInspectionId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,28 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInspectionHistory } from "@/lib/queries";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
InspectionTabs,
|
||||
type InspectionTab,
|
||||
} from "@/components/inspection/InspectionTabs";
|
||||
import {
|
||||
useInspectionHistory,
|
||||
useSiteInspectionHistory,
|
||||
useBatchInspectionHistory,
|
||||
} from "@/lib/queries";
|
||||
import { SearchBar } from "@/components/history/SearchBar";
|
||||
import { InspectionHistoryTable } from "@/components/history/InspectionHistoryTable";
|
||||
import { SiteHistoryTable } from "@/components/history/SiteHistoryTable";
|
||||
import { Pagination } from "@/components/history/Pagination";
|
||||
import { ErrorState } from "@/components/common/ErrorState";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/common/EmptyState";
|
||||
import { ScoreBadge } from "@/components/common/ScoreBadge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime, getScoreTailwindColor } from "@/lib/constants";
|
||||
import { Eye } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BatchInspectionListItem } from "@/types/batch-inspection";
|
||||
import type { Grade } from "@/types/inspection";
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 배치 이력 테이블 (인라인)
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
const { data, isLoading, isError, refetch } = useInspectionHistory(
|
||||
page,
|
||||
searchQuery || undefined
|
||||
);
|
||||
/** 배치 검사 상태 한국어 라벨 */
|
||||
const BATCH_STATUS_LABELS: Record<string, string> = {
|
||||
inspecting: "검사 중",
|
||||
completed: "완료",
|
||||
error: "오류",
|
||||
};
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setPage(1); // 검색 시 1페이지로 리셋
|
||||
interface BatchHistoryTableProps {
|
||||
inspections: BatchInspectionListItem[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function BatchHistoryTable({ inspections, isLoading }: BatchHistoryTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="배치 검사 이력을 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!inspections || inspections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message="배치 검사 이력이 없습니다"
|
||||
description="URL 목록 파일을 업로드하여 첫 번째 배치 검사를 시작해보세요"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRowClick = (batchInspectionId: string) => {
|
||||
router.push(`/batch-inspections/${batchInspectionId}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
<TableHead className="text-center hidden sm:table-cell">
|
||||
종합 점수
|
||||
</TableHead>
|
||||
<TableHead className="text-center hidden md:table-cell">
|
||||
등급
|
||||
</TableHead>
|
||||
<TableHead className="text-center hidden md:table-cell">
|
||||
URL 수
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">검사일</TableHead>
|
||||
<TableHead className="text-center">액션</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inspections.map((item) => (
|
||||
<TableRow
|
||||
key={item.batch_inspection_id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleRowClick(item.batch_inspection_id)}
|
||||
>
|
||||
<TableCell className="max-w-[200px] lg:max-w-[300px]">
|
||||
<span className="truncate block text-sm font-medium">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground sm:hidden">
|
||||
{formatDateTime(item.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-1 rounded-full",
|
||||
item.status === "completed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: item.status === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-yellow-100 text-yellow-700"
|
||||
)}
|
||||
>
|
||||
{BATCH_STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center hidden sm:table-cell">
|
||||
{item.overall_score !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
getScoreTailwindColor(item.overall_score)
|
||||
)}
|
||||
>
|
||||
{item.overall_score}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center hidden md:table-cell">
|
||||
{item.grade ? (
|
||||
<ScoreBadge grade={item.grade as Grade} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center hidden md:table-cell text-sm">
|
||||
{item.total_urls}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
|
||||
{formatDateTime(item.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(item.batch_inspection_id);
|
||||
}}
|
||||
aria-label="결과 보기"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 메인 이력 페이지
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [activeTab, setActiveTab] = useState<InspectionTab>("single");
|
||||
|
||||
// 각 탭별 독립적 페이지/검색 state
|
||||
const [singlePage, setSinglePage] = useState(1);
|
||||
const [singleSearch, setSingleSearch] = useState("");
|
||||
|
||||
const [sitePage, setSitePage] = useState(1);
|
||||
|
||||
const [batchPage, setBatchPage] = useState(1);
|
||||
const [batchSearch, setBatchSearch] = useState("");
|
||||
|
||||
// 각 탭별 쿼리
|
||||
const singleQuery = useInspectionHistory(
|
||||
singlePage,
|
||||
singleSearch || undefined
|
||||
);
|
||||
const siteQuery = useSiteInspectionHistory(sitePage);
|
||||
const batchQuery = useBatchInspectionHistory(batchPage, batchSearch || undefined);
|
||||
|
||||
const handleSingleSearch = (query: string) => {
|
||||
setSingleSearch(query);
|
||||
setSinglePage(1);
|
||||
};
|
||||
|
||||
const handleBatchSearch = (query: string) => {
|
||||
setBatchSearch(query);
|
||||
setBatchPage(1);
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
@ -30,35 +210,108 @@ export default function HistoryPage() {
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">검사 이력</h1>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="mb-6 max-w-md">
|
||||
<SearchBar
|
||||
query={searchQuery}
|
||||
onSearch={handleSearch}
|
||||
placeholder="URL 검색..."
|
||||
/>
|
||||
</div>
|
||||
{/* 탭 */}
|
||||
<InspectionTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* 테이블 또는 에러 */}
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="검사 이력을 불러올 수 없습니다"
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
{/* single 탭 */}
|
||||
{activeTab === "single" && (
|
||||
<>
|
||||
<InspectionHistoryTable
|
||||
inspections={data?.items}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{data && data.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={data.total_pages}
|
||||
onPageChange={handlePageChange}
|
||||
<div className="mb-6 max-w-md">
|
||||
<SearchBar
|
||||
query={singleSearch}
|
||||
onSearch={handleSingleSearch}
|
||||
placeholder="URL 검색..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{singleQuery.isError ? (
|
||||
<ErrorState
|
||||
message="검사 이력을 불러올 수 없습니다"
|
||||
onRetry={() => singleQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<InspectionHistoryTable
|
||||
inspections={singleQuery.data?.items}
|
||||
isLoading={singleQuery.isLoading}
|
||||
/>
|
||||
{singleQuery.data && singleQuery.data.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={singleQuery.data.page}
|
||||
totalPages={singleQuery.data.total_pages}
|
||||
onPageChange={(p) => {
|
||||
setSinglePage(p);
|
||||
scrollToTop();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* site 탭 */}
|
||||
{activeTab === "site" && (
|
||||
<>
|
||||
{siteQuery.isError ? (
|
||||
<ErrorState
|
||||
message="사이트 크롤링 이력을 불러올 수 없습니다"
|
||||
onRetry={() => siteQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SiteHistoryTable
|
||||
inspections={siteQuery.data?.items}
|
||||
isLoading={siteQuery.isLoading}
|
||||
/>
|
||||
{siteQuery.data && siteQuery.data.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={siteQuery.data.page}
|
||||
totalPages={siteQuery.data.total_pages}
|
||||
onPageChange={(p) => {
|
||||
setSitePage(p);
|
||||
scrollToTop();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* batch 탭 */}
|
||||
{activeTab === "batch" && (
|
||||
<>
|
||||
<div className="mb-6 max-w-md">
|
||||
<SearchBar
|
||||
query={batchSearch}
|
||||
onSearch={handleBatchSearch}
|
||||
placeholder="배치 이름 검색..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{batchQuery.isError ? (
|
||||
<ErrorState
|
||||
message="배치 검사 이력을 불러올 수 없습니다"
|
||||
onRetry={() => batchQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BatchHistoryTable
|
||||
inspections={batchQuery.data?.items}
|
||||
isLoading={batchQuery.isLoading}
|
||||
/>
|
||||
{batchQuery.data && batchQuery.data.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={batchQuery.data.page}
|
||||
totalPages={batchQuery.data.total_pages}
|
||||
onPageChange={(p) => {
|
||||
setBatchPage(p);
|
||||
scrollToTop();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { UrlInputForm } from "@/components/inspection/UrlInputForm";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
InspectionTabs,
|
||||
type InspectionTab,
|
||||
} from "@/components/inspection/InspectionTabs";
|
||||
import { SinglePageForm } from "@/components/inspection/SinglePageForm";
|
||||
import { SiteCrawlForm } from "@/components/inspection/SiteCrawlForm";
|
||||
import { BatchUploadForm } from "@/components/inspection/BatchUploadForm";
|
||||
import { RecentInspections } from "@/components/inspection/RecentInspections";
|
||||
import { RecentSiteInspections } from "@/components/inspection/RecentSiteInspections";
|
||||
import { RecentBatchInspections } from "@/components/inspection/RecentBatchInspections";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeTab, setActiveTab] = useState<InspectionTab>("single");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{/* 히어로 섹션 */}
|
||||
@ -22,11 +33,18 @@ export default function HomePage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* URL 입력 폼 */}
|
||||
<UrlInputForm />
|
||||
{/* 탭 */}
|
||||
<InspectionTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* 최근 검사 이력 */}
|
||||
<RecentInspections />
|
||||
{/* 탭별 폼 */}
|
||||
{activeTab === "single" && <SinglePageForm />}
|
||||
{activeTab === "site" && <SiteCrawlForm />}
|
||||
{activeTab === "batch" && <BatchUploadForm />}
|
||||
|
||||
{/* 탭별 최근 이력 */}
|
||||
{activeTab === "single" && <RecentInspections />}
|
||||
{activeTab === "site" && <RecentSiteInspections />}
|
||||
{activeTab === "batch" && <RecentBatchInspections />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ export default function SiteInspectionProgressPage({
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">
|
||||
사이트 전체 검사
|
||||
사이트 크롤링 검사
|
||||
</h1>
|
||||
<SiteCrawlProgress siteInspectionId={id} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user