diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-02 09:52:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-02 09:52:21 +0000 |
| commit | fccb00d15466cd0b2d861163663a5070c768ff77 (patch) | |
| tree | 4b14b27417ebeb873a9d4b4d7b5c64f6e1d78135 /app | |
| parent | 72f212f717f136e875e7623404a5ddd4c5268901 (diff) | |
(대표님) OCR 박진석프로 요청 대응, rfq 변경된 요구사항 구현
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/rfq-last/page.tsx | 325 | ||||
| -rw-r--r-- | app/api/ocr/export/route.ts | 141 |
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 |
