From fccb00d15466cd0b2d861163663a5070c768ff77 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 2 Sep 2025 09:52:21 +0000 Subject: (대표님) OCR 박진석프로 요청 대응, rfq 변경된 요구사항 구현 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/rfq-last/page.tsx | 325 ++++++++++++++++++++++++++++++++ app/api/ocr/export/route.ts | 141 ++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 app/[lng]/evcp/(evcp)/rfq-last/page.tsx create mode 100644 app/api/ocr/export/route.ts (limited to 'app') 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; +} + +// 프로세스 안내 팝오버 컴포넌트 +function ProcessGuidePopover() { + return ( + + + + + +
+
+

RFQ 프로세스

+

+ 견적 요청 관리 프로세스입니다. +

+
+ +
+
+
+ 1 +
+
+

RFQ 생성

+

ECC 또는 수동으로 RFQ를 생성합니다.

+
+
+
+
+ 2 +
+
+

구매담당 지정

+

각 RFQ에 구매 담당자를 지정합니다.

+
+
+
+
+ 3 +
+
+

업체 선정 및 발송

+

Short List를 확정하고 RFQ를 발송합니다.

+
+
+
+
+ 4 +
+
+

견적 접수

+

업체로부터 견적서를 접수받습니다.

+
+
+
+
+ 5 +
+
+

최종 업체 선정

+

TBE를 통해 최종 업체를 선정합니다.

+
+
+
+ +
+
RFQ 유형
+
+
+ + 일반견적: + 일반 견적 요청 +
+
+ + ITB: + 프로젝트 기반 입찰 +
+
+ + RFQ: + PR 기반 견적 요청 +
+
+
+
+
+
+ ); +} + +// 탭별 데이터 카운트를 가져오는 함수 +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 ( + + {/* 헤더 */} +
+
+

+ RFQ 관리 +

+ + +
+
+ + {/* 탭 컨테이너 */} + + + + + + ITB + {tabCounts.itb > 0 && ( + + {tabCounts.itb} + + )} + + + + RFQ + {tabCounts.rfq > 0 && ( + + {tabCounts.rfq} + + )} + + + + 일반견적 + {tabCounts.general > 0 && ( + + {tabCounts.general} + + )} + + + + + {/* 일반견적 탭 */} + + + } + > + + + + + {/* ITB 탭 */} + + + } + > + + + + + {/* RFQ(PR) 탭 */} + + + } + > + + + + +
+ ); +} \ 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 { + 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 -- cgit v1.2.3