summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/rfq-last/page.tsx325
-rw-r--r--app/api/ocr/export/route.ts141
2 files changed, 466 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/rfq-last/page.tsx b/app/[lng]/evcp/(evcp)/rfq-last/page.tsx
new file mode 100644
index 00000000..27936560
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/rfq-last/page.tsx
@@ -0,0 +1,325 @@
+// app/rfq/page.tsx
+
+import * as React from "react";
+import { Metadata } from "next";
+import { type SearchParams } from "@/types/table";
+import { Shell } from "@/components/shell";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { HelpCircle, Package, FileText, ClipboardList, Layers } from "lucide-react";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { RfqTable } from "@/lib/rfq-last/table/rfq-table";
+import { getRfqs } from "@/lib/rfq-last/service";
+import { searchParamsRfqCache } from "@/lib/rfq-last/validations";
+import { InformationButton } from "@/components/information/information-button";
+
+export const metadata: Metadata = {
+ title: "RFQ 관리",
+ description: "RFQ 견적 요청 관리 시스템",
+};
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+}
+
+// 프로세스 안내 팝오버 컴포넌트
+function ProcessGuidePopover() {
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="ghost" size="icon" className="h-6 w-6">
+ <HelpCircle className="h-4 w-4 text-muted-foreground" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-96" align="start">
+ <div className="space-y-3">
+ <div className="space-y-1">
+ <h4 className="font-medium">RFQ 프로세스</h4>
+ <p className="text-sm text-muted-foreground">
+ 견적 요청 관리 프로세스입니다.
+ </p>
+ </div>
+
+ <div className="space-y-3 text-sm">
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 1
+ </div>
+ <div>
+ <p className="font-medium">RFQ 생성</p>
+ <p className="text-muted-foreground">ECC 또는 수동으로 RFQ를 생성합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 2
+ </div>
+ <div>
+ <p className="font-medium">구매담당 지정</p>
+ <p className="text-muted-foreground">각 RFQ에 구매 담당자를 지정합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 3
+ </div>
+ <div>
+ <p className="font-medium">업체 선정 및 발송</p>
+ <p className="text-muted-foreground">Short List를 확정하고 RFQ를 발송합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 4
+ </div>
+ <div>
+ <p className="font-medium">견적 접수</p>
+ <p className="text-muted-foreground">업체로부터 견적서를 접수받습니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 5
+ </div>
+ <div>
+ <p className="font-medium">최종 업체 선정</p>
+ <p className="text-muted-foreground">TBE를 통해 최종 업체를 선정합니다.</p>
+ </div>
+ </div>
+ </div>
+
+ <div className="border-t pt-3 space-y-2">
+ <h5 className="font-medium text-sm">RFQ 유형</h5>
+ <div className="space-y-2 text-xs">
+ <div className="flex items-center gap-2">
+ <FileText className="h-3 w-3 text-blue-600" />
+ <span className="font-medium">일반견적:</span>
+ <span className="text-muted-foreground">일반 견적 요청</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Package className="h-3 w-3 text-purple-600" />
+ <span className="font-medium">ITB:</span>
+ <span className="text-muted-foreground">프로젝트 기반 입찰</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <ClipboardList className="h-3 w-3 text-green-600" />
+ <span className="font-medium">RFQ:</span>
+ <span className="text-muted-foreground">PR 기반 견적 요청</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+// 탭별 데이터 카운트를 가져오는 함수
+async function getTabCounts() {
+ try {
+ const [allData, generalData, itbData, rfqData] = await Promise.all([
+ getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "general" }),
+ getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "itb" }),
+ getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "rfq" }),
+ ]);
+
+ return {
+ general: generalData.total || 0,
+ itb: itbData.total || 0,
+ rfq: rfqData?.total || 0,
+ };
+ } catch (error) {
+ console.error("Error fetching tab counts:", error);
+ return {
+ all: 0,
+ general: 0,
+ itb: 0,
+ rfq: 0,
+ };
+ }
+}
+
+export default async function RfqPage(props: RfqPageProps) {
+ const searchParams = await props.searchParams;
+
+ // nuqs 기반 파라미터 파싱
+ const search = searchParamsRfqCache.parse(searchParams);
+
+ // 탭별 데이터 카운트 가져오기
+ const tabCounts = await getTabCounts();
+
+ // 현재 선택된 탭 (URL 파라미터에서 가져오거나 기본값 'all')
+ const currentTab = search.rfqCategory || "all";
+
+ // 각 탭별로 데이터 프리패칭
+// const allData = await getRfqs({ ...search, rfqCategory: "all" });
+ const generalData = await getRfqs({ ...search, rfqCategory: "general" });
+ const itbData = await getRfqs({ ...search, rfqCategory: "itb" });
+ const rfqData = await getRfqs({ ...search, rfqCategory: "rfq" });
+
+ return (
+ <Shell className="gap-4">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ RFQ 관리
+ </h2>
+ <InformationButton pagePath="rfq" />
+ <ProcessGuidePopover />
+ </div>
+ </div>
+
+ {/* 탭 컨테이너 */}
+ <Tabs defaultValue={currentTab} className="w-full">
+ <TabsList className="grid w-full max-w-[600px] grid-cols-4">
+
+ <TabsTrigger value="itb" className="relative">
+ <Package className="mr-2 h-4 w-4" />
+ ITB
+ {tabCounts.itb > 0 && (
+ <Badge variant="secondary" className="ml-2 text-xs">
+ {tabCounts.itb}
+ </Badge>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="rfq" className="relative">
+ <ClipboardList className="mr-2 h-4 w-4" />
+ RFQ
+ {tabCounts.rfq > 0 && (
+ <Badge variant="secondary" className="ml-2 text-xs">
+ {tabCounts.rfq}
+ </Badge>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="general" className="relative">
+ <FileText className="mr-2 h-4 w-4" />
+ 일반견적
+ {tabCounts.general > 0 && (
+ <Badge variant="secondary" className="ml-2 text-xs">
+ {tabCounts.general}
+ </Badge>
+ )}
+ </TabsTrigger>
+ </TabsList>
+
+
+ {/* 일반견적 탭 */}
+ <TabsContent value="general" className="mt-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={13}
+ searchableColumnCount={4}
+ filterableColumnCount={8}
+ cellWidths={[
+ "3rem", // checkbox
+ "9rem", // rfqCode
+ "7rem", // status
+ "8rem", // rfqType
+ "15rem", // rfqTitle
+ "8rem", // projectCode
+ "12rem", // projectName
+ "8rem", // picName
+ "5rem", // rfqSendDate
+ "5rem", // dueDate
+ "5rem", // vendorCount
+ "5rem", // quotationReceived
+ "5rem", // actions
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqTable
+ data={generalData}
+ rfqCategory="general"
+ />
+ </React.Suspense>
+ </TabsContent>
+
+ {/* ITB 탭 */}
+ <TabsContent value="itb" className="mt-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={14}
+ searchableColumnCount={4}
+ filterableColumnCount={9}
+ cellWidths={[
+ "3rem", // checkbox
+ "9rem", // rfqCode
+ "7rem", // status
+ "10rem", // projectCompany
+ "8rem", // projectFlag
+ "10rem", // projectSite
+ "6rem", // smCode
+ "8rem", // projectCode
+ "12rem", // projectName
+ "8rem", // picName
+ "5rem", // rfqSendDate
+ "5rem", // dueDate
+ "5rem", // vendorCount
+ "5rem", // actions
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqTable
+ data={itbData}
+ rfqCategory="itb"
+ />
+ </React.Suspense>
+ </TabsContent>
+
+ {/* RFQ(PR) 탭 */}
+ <TabsContent value="rfq" className="mt-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={15}
+ searchableColumnCount={4}
+ filterableColumnCount={10}
+ cellWidths={[
+ "3rem", // checkbox
+ "9rem", // rfqCode
+ "7rem", // status
+ "8rem", // prNumber
+ "8rem", // prIssueDate
+ "8rem", // series
+ "8rem", // projectCode
+ "12rem", // projectName
+ "8rem", // itemCode
+ "12rem", // itemName
+ "8rem", // picName
+ "5rem", // rfqSendDate
+ "5rem", // dueDate
+ "5rem", // vendorCount
+ "5rem", // actions
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqTable
+ data={rfqData}
+ rfqCategory="rfq"
+ />
+ </React.Suspense>
+ </TabsContent>
+ </Tabs>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/api/ocr/export/route.ts b/app/api/ocr/export/route.ts
new file mode 100644
index 00000000..49e39cc7
--- /dev/null
+++ b/app/api/ocr/export/route.ts
@@ -0,0 +1,141 @@
+import { NextRequest, NextResponse } from 'next/server'
+import ExcelJS from 'exceljs'
+import db from '@/db/db'
+import { ocrRows, users } from '@/db/schema'
+import { eq, desc } from 'drizzle-orm'
+import { PassThrough } from 'stream'
+
+export async function GET(request: NextRequest) {
+ try {
+ // ExcelJS 워크북 생성 (스트리밍 모드)
+ const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
+ stream: new PassThrough(),
+ useStyles: true,
+ useSharedStrings: true
+ })
+
+ // 워크시트 추가
+ const worksheet = workbook.addWorksheet('OCR Data', {
+ properties: { defaultColWidth: 15 }
+ })
+
+ // 헤더 정의
+ worksheet.columns = [
+ { header: 'ID', key: 'id', width: 10 },
+ { header: 'Table ID', key: 'tableId', width: 15 },
+ { header: 'Session ID', key: 'sessionId', width: 20 },
+ { header: 'Row Index', key: 'rowIndex', width: 12 },
+ { header: 'Report No', key: 'reportNo', width: 15 },
+ { header: 'File Name', key: 'fileName', width: 30 },
+ { header: 'Inspection Date', key: 'inspectionDate', width: 15 },
+ { header: 'No', key: 'no', width: 10 },
+ { header: 'Identification No', key: 'identificationNo', width: 20 },
+ { header: 'Tag No', key: 'tagNo', width: 15 },
+ { header: 'Joint No', key: 'jointNo', width: 15 },
+ { header: 'Joint Type', key: 'jointType', width: 15 },
+ { header: 'Welding Date', key: 'weldingDate', width: 15 },
+ { header: 'Confidence', key: 'confidence', width: 12 },
+ { header: 'Source Table', key: 'sourceTable', width: 15 },
+ { header: 'Source Row', key: 'sourceRow', width: 12 },
+ { header: 'User Name', key: 'userName', width: 20 },
+ { header: 'User Email', key: 'userEmail', width: 25 },
+ { header: 'Created At', key: 'createdAt', width: 20 },
+ ]
+
+ // 헤더 스타일 적용
+ worksheet.getRow(1).font = { bold: true }
+ worksheet.getRow(1).fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ }
+
+ // 청크 단위로 데이터 가져와서 스트리밍
+ const CHUNK_SIZE = 1000
+ let offset = 0
+ let hasMore = true
+
+ while (hasMore) {
+ const chunk = await db
+ .select({
+ id: ocrRows.id,
+ tableId: ocrRows.tableId,
+ sessionId: ocrRows.sessionId,
+ rowIndex: ocrRows.rowIndex,
+ reportNo: ocrRows.reportNo,
+ fileName: ocrRows.fileName,
+ inspectionDate: ocrRows.inspectionDate,
+ no: ocrRows.no,
+ identificationNo: ocrRows.identificationNo,
+ tagNo: ocrRows.tagNo,
+ jointNo: ocrRows.jointNo,
+ jointType: ocrRows.jointType,
+ weldingDate: ocrRows.weldingDate,
+ confidence: ocrRows.confidence,
+ sourceTable: ocrRows.sourceTable,
+ sourceRow: ocrRows.sourceRow,
+ userId: ocrRows.userId,
+ createdAt: ocrRows.createdAt,
+ userName: users.name,
+ userEmail: users.email,
+ })
+ .from(ocrRows)
+ .leftJoin(users, eq(ocrRows.userId, users.id))
+ .orderBy(desc(ocrRows.createdAt))
+ .limit(CHUNK_SIZE)
+ .offset(offset)
+
+ if (chunk.length === 0) {
+ hasMore = false
+ } else {
+ // 각 행을 워크시트에 추가
+ for (const row of chunk) {
+ worksheet.addRow({
+ ...row,
+ createdAt: row.createdAt ? new Date(row.createdAt).toLocaleString('ko-KR') : '',
+ inspectionDate: row.inspectionDate ? new Date(row.inspectionDate).toLocaleDateString('ko-KR') : '',
+ weldingDate: row.weldingDate ? new Date(row.weldingDate).toLocaleDateString('ko-KR') : '',
+ }).commit() // commit()으로 메모리 즉시 해제
+ }
+
+ offset += chunk.length
+
+ if (chunk.length < CHUNK_SIZE) {
+ hasMore = false
+ }
+ }
+ }
+
+ // 워크시트 커밋
+ worksheet.commit()
+
+ // 스트림을 버퍼로 변환
+ const buffer = await streamToBuffer(workbook)
+
+ // 응답 헤더 설정
+ const headers = new Headers()
+ headers.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+ headers.set('Content-Disposition', `attachment; filename="OCR_Export_${new Date().toISOString().split('T')[0]}.xlsx"`)
+
+ return new NextResponse(buffer, { headers })
+
+ } catch (error) {
+ console.error('Export error:', error)
+ return NextResponse.json({ error: 'Export failed' }, { status: 500 })
+ }
+}
+
+// 스트림을 버퍼로 변환하는 헬퍼 함수
+async function streamToBuffer(workbook: ExcelJS.stream.xlsx.WorkbookWriter): Promise<Buffer> {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = []
+
+ const stream = (workbook as any).stream as PassThrough
+
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk))
+ stream.on('end', () => resolve(Buffer.concat(chunks)))
+ stream.on('error', reject)
+
+ workbook.commit()
+ })
+} \ No newline at end of file