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:
jungwoo choi
2026-02-13 19:15:27 +09:00
parent 9bb844c5e1
commit 8326c84be9
32 changed files with 3700 additions and 61 deletions

View 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>
);
}

View 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>
);
}

View File

@ -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();
}}
/>
)}
</>
)}
</>
)}

View File

@ -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>
);
}

View File

@ -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>