summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx61
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx126
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx219
-rw-r--r--app/not-found.tsx285
6 files changed, 725 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx
new file mode 100644
index 00000000..05b856e5
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx
@@ -0,0 +1,61 @@
+import { searchParamsCache } from "@/lib/techsales-rfq/validations"
+import { getTechSalesRfqsWithJoin } from "@/lib/techsales-rfq/service"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: RfqPageProps) {
+ // searchParams를 await하여 resolve
+ const searchParams = await props.searchParams
+
+ // 파라미터 파싱
+ const search = searchParamsCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getTechSalesRfqsWithJoin({
+ ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
+ filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
+ })
+ ])
+
+ return (
+ <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기술영업-조선 RFQ
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 영역 - 남은 공간 모두 차지 */}
+ <div className="flex-1 min-h-0">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQListTable promises={promises} className="h-full" />
+ </React.Suspense>
+ </div>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx
new file mode 100644
index 00000000..40be6773
--- /dev/null
+++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx
@@ -0,0 +1,17 @@
+import { Shell } from "@/components/shell";
+
+export default function TechSalesRfqShipPage() {
+ return (
+ <Shell className="gap-6">
+ <div>
+ <h1 className="text-2xl font-bold">기술영업 - 해양 Hull/Top RFQ</h1>
+ <p className="text-muted-foreground">
+ 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다.
+ </p>
+ <p className="text-muted-foreground">
+ 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다.
+ </p>
+ </div>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx
new file mode 100644
index 00000000..40be6773
--- /dev/null
+++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx
@@ -0,0 +1,17 @@
+import { Shell } from "@/components/shell";
+
+export default function TechSalesRfqShipPage() {
+ return (
+ <Shell className="gap-6">
+ <div>
+ <h1 className="text-2xl font-bold">기술영업 - 해양 Hull/Top RFQ</h1>
+ <p className="text-muted-foreground">
+ 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다.
+ </p>
+ <p className="text-muted-foreground">
+ 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다.
+ </p>
+ </div>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx
new file mode 100644
index 00000000..d748fc46
--- /dev/null
+++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx
@@ -0,0 +1,126 @@
+// app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx - 기술영업 RFQ 견적 응답 페이지
+import { Metadata } from "next"
+import { notFound } from "next/navigation"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { BackButton } from "@/components/ui/back-button"
+import { getTechSalesVendorQuotation } from "@/lib/techsales-rfq/service"
+import { formatDate } from "@/lib/utils"
+import { TechSalesQuotationTabs } from "@/lib/techsales-rfq/vendor-response/detail/quotation-tabs"
+
+interface TechSalesVendorQuotationDetailPageProps {
+ params: Promise<{
+ id: string
+ lng: string
+ }>
+ searchParams: Promise<{
+ tab?: string
+ }>
+}
+
+export async function generateMetadata(): Promise<Metadata> {
+ return {
+ title: "기술영업 RFQ 견적서 상세",
+ description: "기술영업 RFQ 견적서 상세 정보 및 응답",
+ }
+}
+
+export default async function TechSalesVendorQuotationDetailPage({
+ params,
+ searchParams,
+}: TechSalesVendorQuotationDetailPageProps) {
+ const { id } = await params
+ const { tab } = await searchParams
+ const quotationId = parseInt(id)
+
+ if (isNaN(quotationId)) {
+ notFound()
+ }
+
+ // 견적서 데이터 조회
+ const { data: quotation, error } = await getTechSalesVendorQuotation(quotationId)
+
+ if (error || !quotation) {
+ notFound()
+ }
+
+ const rfq = quotation.rfq
+ const vendor = quotation.vendor
+
+ // 상태별 배지 색상
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <Shell variant="sidebar" className="gap-4">
+ {/* 헤더 */}
+ <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <h1 className="text-2xl font-bold">견적서 상세</h1>
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>RFQ: {rfq?.rfqCode || "미할당"}</span>
+ <span>•</span>
+ <span>벤더: {vendor?.vendorName}</span>
+ <span>•</span>
+ <span>마감일: {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}</span>
+ </div>
+ </div>
+
+ {/* 우측 상단 버튼 */}
+ <div className="flex items-center justify-end">
+ <BackButton segmentsToRemove={1}>
+ 목록으로 돌아가기
+ </BackButton>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 탭 컨텐츠 */}
+ <div className="flex-1 min-h-0">
+ <TechSalesQuotationTabs
+ quotation={quotation}
+ defaultTab={tab || "project"}
+ />
+ </div>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx
new file mode 100644
index 00000000..5b0ffb61
--- /dev/null
+++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx
@@ -0,0 +1,219 @@
+// app/vendor/quotations/page.tsx
+import * as React from "react";
+import Link from "next/link";
+import { Metadata } from "next";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { LogIn } from "lucide-react";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { Shell } from "@/components/shell";
+import { getValidFilters } from "@/lib/data-table";
+import { type SearchParams } from "@/types/table";
+import { searchParamsVendorRfqCache } from "@/lib/techsales-rfq/validations";
+import {
+ TECH_SALES_QUOTATION_STATUSES,
+ TECH_SALES_QUOTATION_STATUS_CONFIG
+} from "@/db/schema";
+
+import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/techsales-rfq/service";
+import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table";
+
+export const metadata: Metadata = {
+ title: "기술영업 견적서 관리",
+ description: "기술영업 RFQ 견적서를 관리합니다.",
+};
+
+interface VendorQuotationsPageProps {
+ searchParams: SearchParams;
+}
+
+export default async function VendorQuotationsPage({
+ searchParams,
+}: VendorQuotationsPageProps) {
+ // 세션 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return (
+ <Shell>
+ <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
+ <div className="text-center">
+ <h2 className="text-2xl font-bold tracking-tight">로그인이 필요합니다</h2>
+ <p className="text-muted-foreground">
+ 견적서를 확인하려면 로그인해주세요.
+ </p>
+ </div>
+ <Button asChild>
+ <Link href="/api/auth/signin">
+ <LogIn className="mr-2 h-4 w-4" />
+ 로그인
+ </Link>
+ </Button>
+ </div>
+ </Shell>
+ );
+ }
+
+ // 벤더 ID 확인 (사용자의 회사 ID가 벤더 ID)
+ const vendorId = session.user.companyId;
+ if (!vendorId) {
+ return (
+ <Shell>
+ <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
+ <div className="text-center">
+ <h2 className="text-2xl font-bold tracking-tight">회사 정보가 없습니다</h2>
+ <p className="text-muted-foreground">
+ 견적서를 확인하려면 회사 정보가 필요합니다.
+ </p>
+ </div>
+ </div>
+ </Shell>
+ );
+ }
+
+ // 검색 파라미터 파싱 및 검증
+ const search = searchParamsVendorRfqCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ // 견적서 상태별 개수 조회
+ const statusCountsPromise = getQuotationStatusCounts(vendorId.toString());
+
+ // 견적서 목록 조회
+ const quotationsPromise = getVendorQuotations(
+ {
+ flags: search.flags,
+ page: search.page,
+ perPage: search.perPage,
+ sort: search.sort,
+ filters: validFilters,
+ joinOperator: search.joinOperator,
+ search: search.search,
+ from: search.from,
+ to: search.to,
+ },
+ vendorId.toString()
+ );
+
+ return (
+ <Shell variant="fullscreen" className="h-full">
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">기술영업 견적서</h1>
+ <p className="text-muted-foreground">
+ 할당받은 RFQ에 대한 견적서를 작성하고 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ {/* 상태별 개수 카드 */}
+ <div className="flex-shrink-0">
+ <React.Suspense
+ fallback={
+ <div className="w-full overflow-x-auto">
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit">
+ {Array.from({ length: 5 }).map((_, i) => (
+ <Card key={i} className="min-w-[160px]">
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">-</div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+ }
+ >
+ <StatusCards statusCountsPromise={statusCountsPromise} />
+ </React.Suspense>
+ </div>
+
+ {/* 견적서 테이블 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="h-full overflow-auto">
+ <VendorQuotationsTable promises={Promise.all([quotationsPromise.then(result => ({ data: result.data, pageCount: result.pageCount }))])} />
+ </div>
+ </React.Suspense>
+ </div>
+ </div>
+ </Shell>
+ );
+}
+
+// 상태별 개수 카드 컴포넌트
+async function StatusCards({
+ statusCountsPromise,
+}: {
+ statusCountsPromise: Promise<{
+ data: { status: string; count: number }[] | null;
+ error: string | null;
+ }>;
+}) {
+ const { data: statusCounts, error } = await statusCountsPromise;
+
+ if (error || !statusCounts) {
+ return (
+ <div className="w-full overflow-x-auto">
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit">
+ <Card className="min-w-[160px]">
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium truncate">오류</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-red-600">-</div>
+ <p className="text-xs text-muted-foreground truncate">
+ 데이터를 불러올 수 없습니다
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ );
+ }
+
+ // 중앙화된 상태 설정 사용
+ const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ key: statusValue,
+ ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue]
+ }));
+
+ console.log(statusCounts, "statusCounts")
+
+ return (
+ <div className="w-full overflow-x-auto">
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit">
+ {statusEntries.map((status) => (
+ <Card key={status.key} className="min-w-[160px]">
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className={`text-2xl font-bold ${status.color}`}>
+ {statusCounts.find(item => item.status === status.key)?.count || 0}
+ </div>
+ <p className="text-xs text-muted-foreground truncate">
+ {status.description}
+ </p>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 00000000..b966d978
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,285 @@
+'use client'
+
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Separator } from "@/components/ui/separator"
+import { ArrowLeft, Building2, Users, Info, AlertCircle, Copy } from "lucide-react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { useState, useEffect } from "react"
+import { toast } from "sonner"
+
+// 에러 정보 타입
+interface ErrorInfo {
+ timestamp: string;
+ url: string;
+ userAgent: string;
+ platform: string;
+ language: string;
+ cookieEnabled: boolean;
+ onLine: boolean;
+ screen: {
+ width: number;
+ height: number;
+ colorDepth: number;
+ };
+ viewport: {
+ width: number;
+ height: number;
+ };
+ referrer: string;
+ localStorage: boolean;
+ sessionStorage: boolean;
+}
+
+export default function NotFound() {
+ const router = useRouter()
+ const [errorInfo, setErrorInfo] = useState<ErrorInfo | null>(null)
+
+ useEffect(() => {
+ // 브라우저 환경 정보 수집
+ const collectErrorInfo = () => {
+ const info: ErrorInfo = {
+ timestamp: new Date().toISOString(),
+ url: window.location.href,
+ userAgent: navigator.userAgent,
+ platform: navigator.platform,
+ language: navigator.language,
+ cookieEnabled: navigator.cookieEnabled,
+ onLine: navigator.onLine,
+ screen: {
+ width: screen.width,
+ height: screen.height,
+ colorDepth: screen.colorDepth,
+ },
+ viewport: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ },
+ referrer: document.referrer || '직접 접근',
+ localStorage: typeof Storage !== 'undefined',
+ sessionStorage: typeof Storage !== 'undefined',
+ }
+ setErrorInfo(info)
+ }
+
+ collectErrorInfo()
+ }, [])
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ toast.success("클립보드에 복사되었습니다")
+ }).catch(() => {
+ toast.error("복사에 실패했습니다")
+ })
+ }
+
+ const copyErrorInfo = () => {
+ if (!errorInfo) return
+
+ const errorText = `
+=== 404 에러 정보 ===
+시간: ${errorInfo.timestamp}
+URL: ${errorInfo.url}
+리퍼러: ${errorInfo.referrer}
+브라우저: ${errorInfo.userAgent}
+플랫폼: ${errorInfo.platform}
+언어: ${errorInfo.language}
+화면 해상도: ${errorInfo.screen.width}x${errorInfo.screen.height}
+뷰포트: ${errorInfo.viewport.width}x${errorInfo.viewport.height}
+온라인 상태: ${errorInfo.onLine ? '온라인' : '오프라인'}
+쿠키 활성화: ${errorInfo.cookieEnabled ? '예' : '아니오'}
+ `.trim()
+
+ copyToClipboard(errorText)
+ }
+
+ return (
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
+ <Card className="w-full max-w-2xl shadow-lg">
+ <CardHeader className="text-center space-y-4">
+ <div className="mx-auto w-24 h-24 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center">
+ <span className="text-4xl font-bold text-slate-600 dark:text-slate-400">404</span>
+ </div>
+ <CardTitle className="text-3xl font-bold text-slate-900 dark:text-slate-100">
+ 이 페이지는 개발중이거나, 잘못된 URL 입니다.
+ </CardTitle>
+ <CardDescription className="text-lg text-slate-600 dark:text-slate-400">
+ THIS PAGE IS UNDER DEVELOPMENT OR INVALID URL.
+ </CardDescription>
+ </CardHeader>
+
+ <CardContent className="space-y-6">
+ <Separator />
+
+ <div className="text-center text-sm text-slate-500 dark:text-slate-400">
+ 아래 버튼을 통해 원하는 페이지로 이동하세요
+ </div>
+
+ <div className="grid gap-4 sm:grid-cols-3">
+ {/* SHI 사용자 홈 */}
+ <Button
+ asChild
+ variant="outline"
+ className="h-auto p-4 flex flex-col items-center space-y-2 hover:bg-blue-50 hover:border-blue-200 dark:hover:bg-blue-950"
+ >
+ <Link href="/ko/evcp/dashboard">
+ <Building2 className="h-6 w-6 text-blue-600 dark:text-blue-400" />
+ <span className="font-medium">SHI 사용자 홈</span>
+ <span className="text-xs text-slate-500 dark:text-slate-400">EVCP 대시보드</span>
+ </Link>
+ </Button>
+
+ {/* 벤더 홈 */}
+ <Button
+ asChild
+ variant="outline"
+ className="h-auto p-4 flex flex-col items-center space-y-2 hover:bg-green-50 hover:border-green-200 dark:hover:bg-green-950"
+ >
+ <Link href="/ko/partners">
+ <Users className="h-6 w-6 text-green-600 dark:text-green-400" />
+ <span className="font-medium">벤더 홈</span>
+ <span className="text-xs text-slate-500 dark:text-slate-400">협력업체 포털</span>
+ </Link>
+ </Button>
+
+ {/* 뒤로 가기 */}
+ <Button
+ onClick={() => router.back()}
+ variant="outline"
+ className="h-auto p-4 flex flex-col items-center space-y-2 hover:bg-slate-50 hover:border-slate-200 dark:hover:bg-slate-800"
+ >
+ <ArrowLeft className="h-6 w-6 text-slate-600 dark:text-slate-400" />
+ <span className="font-medium">뒤로 가기</span>
+ <span className="text-xs text-slate-500 dark:text-slate-400">이전 페이지로</span>
+ </Button>
+ </div>
+
+ <Separator />
+
+ <div className="flex justify-center gap-4">
+
+
+ {/* 에러 정보 다이얼로그 */}
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <Info className="h-4 w-4 mr-2" />
+ 에러 정보
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertCircle className="h-5 w-5" />
+ 에러 상세 정보
+ </DialogTitle>
+ <DialogDescription>
+ 기술 지원을 위한 상세 환경 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {errorInfo && (
+ <div className="space-y-4">
+ <div className="flex justify-end">
+ <Button onClick={copyErrorInfo} size="sm" variant="outline">
+ <Copy className="h-4 w-4 mr-2" />
+ 전체 복사
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">발생 시간</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.timestamp}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">요청 URL</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono break-all">
+ {errorInfo.url}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">이전 페이지</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono break-all">
+ {errorInfo.referrer}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">브라우저 정보</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono break-all">
+ {errorInfo.userAgent}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">플랫폼</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.platform}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">언어</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.language}
+ </div>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">화면 해상도</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.screen.width} × {errorInfo.screen.height}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">뷰포트 크기</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.viewport.width} × {errorInfo.viewport.height}
+ </div>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-3 gap-4">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">온라인 상태</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.onLine ? '온라인' : '오프라인'}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">쿠키 활성화</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.cookieEnabled ? '예' : '아니오'}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">색상 깊이</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.screen.colorDepth}bit
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ No newline at end of file