From 37f55540833c2d5894513eca9fc8f7c6233fc2d2 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 29 May 2025 05:17:13 +0000 Subject: (대표님) 0529 14시 16분 변경사항 저장 (Vendor Data, Docu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/items/page.tsx | 47 +++-- app/[lng]/evcp/(evcp)/rfq-tech/[id]/layout.tsx | 6 +- app/[lng]/partners/(partners)/cbe-tech/page.tsx | 86 ++++++++ app/[lng]/partners/(partners)/rfq-tech/page.tsx | 133 ++++++++++++ app/[lng]/partners/(partners)/tbe-tech/page.tsx | 85 ++++++++ .../[formId]/[projectId]/[contractId]/page.tsx | 3 +- app/api/table/items/infinite/route.ts | 232 +++++++++++++++++++++ 7 files changed, 565 insertions(+), 27 deletions(-) create mode 100644 app/[lng]/partners/(partners)/cbe-tech/page.tsx create mode 100644 app/[lng]/partners/(partners)/rfq-tech/page.tsx create mode 100644 app/[lng]/partners/(partners)/tbe-tech/page.tsx create mode 100644 app/api/table/items/infinite/route.ts (limited to 'app') diff --git a/app/[lng]/evcp/(evcp)/items/page.tsx b/app/[lng]/evcp/(evcp)/items/page.tsx index 144689ff..31dcaf11 100644 --- a/app/[lng]/evcp/(evcp)/items/page.tsx +++ b/app/[lng]/evcp/(evcp)/items/page.tsx @@ -1,7 +1,7 @@ +// app/items/page.tsx (업데이트) import * as React from "react" import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" import { Skeleton } from "@/components/ui/skeleton" import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" import { Shell } from "@/components/shell" @@ -9,7 +9,6 @@ import { searchParamsCache } from "@/lib/items/validations" import { getItems } from "@/lib/items/service" import { ItemsTable } from "@/lib/items/table/items-table" - interface IndexPageProps { searchParams: Promise } @@ -17,16 +16,21 @@ interface IndexPageProps { export default async function IndexPage(props: IndexPageProps) { const searchParams = await props.searchParams const search = searchParamsCache.parse(searchParams) + + // pageSize 기반으로 모드 자동 결정 + const isInfiniteMode = search.perPage >= 1_000_000 - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getItems({ - ...search, - filters: validFilters, - }), + console.log('Page searchParams:', searchParams) + console.log('Parsed search:', search) + console.log('isInfiniteMode (pageSize >= 1M):', isInfiniteMode) - ]) + // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 + // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 + const promises = isInfiniteMode + ? null + : Promise.all([ + getItems(search), // searchParamsCache의 결과를 그대로 사용 + ]) return ( @@ -37,25 +41,21 @@ export default async function IndexPage(props: IndexPageProps) { Package Items

- Item을 등록하고 관리할 수 있습니다.{" "} - {/* - - 버튼 - - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} + {/* Item을 등록하고 관리할 수 있습니다. */} + {isInfiniteMode && ( + + 무한 스크롤 모드 + + )}

}> - {/* */} + {/* DateRangePicker 등 추가 컴포넌트 */} + } > + {/* 통합된 ItemsTable 컴포넌트 사용 */}
) -} +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/rfq-tech/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/rfq-tech/[id]/layout.tsx index 35f76a76..0bb62fe0 100644 --- a/app/[lng]/evcp/(evcp)/rfq-tech/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/rfq-tech/[id]/layout.tsx @@ -33,15 +33,15 @@ export default async function RfqLayout({ const sidebarNavItems = [ { title: "Matched Vendors", - href: `/${lng}/evcp/rfq/${id}`, + href: `/${lng}/evcp/rfq-tech/${id}`, }, { title: "TBE", - href: `/${lng}/evcp/rfq/${id}/tbe`, + href: `/${lng}/evcp/rfq-tech/${id}/tbe`, }, { title: "CBE", - href: `/${lng}/evcp/rfq/${id}/cbe`, + href: `/${lng}/evcp/rfq-tech/${id}/cbe`, }, ] diff --git a/app/[lng]/partners/(partners)/cbe-tech/page.tsx b/app/[lng]/partners/(partners)/cbe-tech/page.tsx new file mode 100644 index 00000000..94e3825d --- /dev/null +++ b/app/[lng]/partners/(partners)/cbe-tech/page.tsx @@ -0,0 +1,86 @@ +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { getCBEbyVendorId, } from "@/lib/rfqs-tech/service" +import { searchParamsCBECache } from "@/lib/rfqs-tech/validations" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { TbeVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table" +import * as React from "react" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { CbeVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table" + +interface IndexPageProps { + // Next.js 13 App Router에서 기본으로 주어지는 객체들 + params: { + lng: string + id: string + } + searchParams: Promise +} + +export default async function CBEPage(props: IndexPageProps) { + const resolvedParams = await props.params + const lng = resolvedParams.lng + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsCBECache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" + + const idAsNumber = Number(vendorId) + + const promises = Promise.all([ + getCBEbyVendorId({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) + + + return ( + +
+
+
+

+ Commercial Bid Evaluation +

+

+ CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/partners/(partners)/rfq-tech/page.tsx b/app/[lng]/partners/(partners)/rfq-tech/page.tsx new file mode 100644 index 00000000..e3e5895a --- /dev/null +++ b/app/[lng]/partners/(partners)/rfq-tech/page.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { searchParamsRfqsForVendorsCache } from "@/lib/rfqs-tech/validations" +import { RfqsVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { LogIn } from "lucide-react" +import { getRfqResponsesForVendor } from "@/lib/tech-vendor-rfq-response/service" + +interface IndexPageProps { + searchParams: Promise +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsRfqsForVendorsCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // Get session + const session = await getServerSession(authOptions) + + // Check if user is logged in + if (!session || !session.user) { + // Return login required UI instead of redirecting + return ( + +
+
+

+ RFQ +

+

+ RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. +

+
+
+ +
+
+

로그인이 필요합니다

+

+ RFQ를 확인하려면 먼저 로그인하세요. +

+ +
+
+
+ ) + } + + // User is logged in, proceed with vendor ID + const vendorId = session.user.companyId + + // Validate vendorId (should be a number) + const idAsNumber = Number(vendorId) + + if (isNaN(idAsNumber)) { + // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) + return ( + +
+
+

+ RFQ +

+
+
+
+
+

계정 오류

+

+ 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요. +

+
+
+
+ ) + } + + // If we got here, we have a valid vendor ID + const promises = Promise.all([ + getRfqResponsesForVendor({ + ...search, + filters: validFilters, + }, idAsNumber) + ]) + + return ( + +
+
+
+

+ RFQ +

+

+ RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. +

+
+
+
+ + }> + {/* DateRangePicker can go here */} + + + + } + > + + +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/tbe-tech/page.tsx b/app/[lng]/partners/(partners)/tbe-tech/page.tsx new file mode 100644 index 00000000..69cf3902 --- /dev/null +++ b/app/[lng]/partners/(partners)/tbe-tech/page.tsx @@ -0,0 +1,85 @@ +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { getTBEforVendor } from "@/lib/rfqs-tech/service" +import { searchParamsTBECache } from "@/lib/rfqs-tech/validations" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { TbeVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table" +import * as React from "react" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" + +interface IndexPageProps { + // Next.js 13 App Router에서 기본으로 주어지는 객체들 + params: { + lng: string + id: string + } + searchParams: Promise +} + +export default async function RfqTBEPage(props: IndexPageProps) { + const resolvedParams = await props.params + const lng = resolvedParams.lng + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsTBECache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" + + const idAsNumber = Number(vendorId) + + const promises = Promise.all([ + getTBEforVendor({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) + + + return ( + +
+
+
+

+ Technical Bid Evaluation +

+

+ TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} +

+
+
+
+ + }> + {/* */} + + + } + > + + +
+ ) +} diff --git a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx index 9a305318..f69aa525 100644 --- a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx +++ b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx @@ -48,7 +48,7 @@ export default async function FormPage({ params, searchParams }: IndexPageProps) } // 5) DB 조회 - const { columns, data } = await getFormData(formCode, packageIdAsNumber); + const { columns, data, editableFieldsMap } = await getFormData(formCode, packageIdAsNumber); // 6) formId 및 report temp file 조회 @@ -71,6 +71,7 @@ export default async function FormPage({ params, searchParams }: IndexPageProps) columnsJSON={columns} dataJSON={data} projectId={Number(projectId)} + editableFieldsMap={editableFieldsMap} // 새로 추가 mode={mode} // 모드 전달 /> diff --git a/app/api/table/items/infinite/route.ts b/app/api/table/items/infinite/route.ts new file mode 100644 index 00000000..486c3076 --- /dev/null +++ b/app/api/table/items/infinite/route.ts @@ -0,0 +1,232 @@ +// app/api/table/items/infinite/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { getItemsInfinite, type GetItemsInfiniteInput } from "@/lib/items/service"; + +// URL 파라미터를 GetItemsInfiniteInput으로 변환하는 헬퍼 함수 +function parseUrlParamsToInfiniteInput(searchParams: URLSearchParams): GetItemsInfiniteInput { + // 무한 스크롤 관련 파라미터 + const cursor = searchParams.get("cursor") || undefined; + const limit = parseInt(searchParams.get("limit") || "50"); + + // 기존 searchParamsCache와 동일한 필드들 + const itemCode = searchParams.get("itemCode") || ""; + const itemName = searchParams.get("itemName") || ""; + const description = searchParams.get("description") || ""; + const parentItemCode = searchParams.get("parentItemCode") || ""; + const itemLevel = parseInt(searchParams.get("itemLevel") || "5"); + const deleteFlag = searchParams.get("deleteFlag") || ""; + const unitOfMeasure = searchParams.get("unitOfMeasure") || ""; + const steelType = searchParams.get("steelType") || ""; + const gradeMaterial = searchParams.get("gradeMaterial") || ""; + const changeDate = searchParams.get("changeDate") || ""; + const baseUnitOfMeasure = searchParams.get("baseUnitOfMeasure") || ""; + + // 고급 필터링 관련 + const search = searchParams.get("search") || ""; + const joinOperator = searchParams.get("joinOperator") || "and"; + + // 필터 파라미터 파싱 + let filters: any[] = []; + const filtersParam = searchParams.get("filters"); + if (filtersParam) { + try { + filters = JSON.parse(filtersParam); + } catch (e) { + console.warn("Invalid filters parameter:", e); + filters = []; + } + } + + // 정렬 파라미터 파싱 + let sort: Array<{ id: string; desc: boolean }> = []; + const sortParam = searchParams.get("sort"); + if (sortParam) { + try { + sort = JSON.parse(sortParam); + } catch (e) { + console.warn("Invalid sort parameter:", e); + // 기본 정렬 + sort = [{ id: "createdAt", desc: true }]; + } + } else { + // 정렬이 없으면 기본 정렬 + sort = [{ id: "createdAt", desc: true }]; + } + + // 플래그 파라미터 파싱 + let flags: string[] = []; + const flagsParam = searchParams.get("flags"); + if (flagsParam) { + try { + flags = JSON.parse(flagsParam); + } catch (e) { + console.warn("Invalid flags parameter:", e); + flags = []; + } + } + + // searchParamsCache의 모든 필드를 포함한 입력 객체 생성 + return { + // 무한 스크롤 관련 + cursor, + limit, + + // 기본 필터 필드들 (searchParamsCache와 동일) + itemCode, + itemName, + description, + parentItemCode, + itemLevel, + deleteFlag, + unitOfMeasure, + steelType, + gradeMaterial, + changeDate, + baseUnitOfMeasure, + + // 고급 필터링 + search, + filters, + joinOperator: joinOperator as "and" | "or", + + // 정렬 + sort, + + // 플래그 + flags, + }; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + console.log('API Request URL:', request.url); + console.log('Search Params:', Object.fromEntries(searchParams.entries())); + + // URL 파라미터를 서비스 입력으로 변환 + const input = parseUrlParamsToInfiniteInput(searchParams); + + console.log('Parsed Input:', JSON.stringify(input, null, 2)); + + // 무한 스크롤 서비스 함수 호출 + const result = await getItemsInfinite(input); + + console.log('Service Result:', { + dataCount: result.data.length, + hasNextPage: result.hasNextPage, + nextCursor: result.nextCursor, + total: result.total, + }); + + // 응답에 메타데이터 추가 + const response = { + mode: 'infinite' as const, + ...result, + meta: { + table: 'items', + displayName: 'Package Items', + requestedLimit: input.limit || 50, + actualLimit: Math.min(input.limit || 50, 100), + maxPageSize: 100, + cursor: input.cursor || null, + apiVersion: '1.0', + timestamp: new Date().toISOString(), + supportedFields: [ + 'itemCode', 'itemName', 'description', 'parentItemCode', + 'itemLevel', 'deleteFlag', 'unitOfMeasure', 'steelType', + 'gradeMaterial', 'changeDate', 'baseUnitOfMeasure' + ], + searchableFields: [ + 'itemLevel', 'itemCode', 'itemName', 'description', + 'parentItemCode', 'unitOfMeasure', 'steelType', + 'gradeMaterial', 'baseUnitOfMeasure', 'changeDate' + ], + } + }; + + return NextResponse.json(response); + + } catch (error) { + console.error("Items infinite API error:", error); + + return NextResponse.json({ + mode: 'infinite' as const, + data: [], + hasNextPage: false, + nextCursor: null, + total: 0, + error: { + message: "Failed to fetch items", + details: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date().toISOString(), + stack: process.env.NODE_ENV === 'development' ? + (error instanceof Error ? error.stack : undefined) : + undefined, + } + }, { + status: 500 + }); + } +} + +// OPTIONS 메서드로 API 정보 제공 +export async function OPTIONS() { + return NextResponse.json({ + table: 'items', + displayName: 'Package Items', + mode: 'infinite', + supportedMethods: ['GET'], + maxPageSize: 100, + defaultPageSize: 50, + + // 지원되는 모든 파라미터 + supportedParams: { + // 무한 스크롤 관련 + cursor: { type: 'string', description: 'Cursor for pagination' }, + limit: { type: 'number', description: 'Number of items per page', max: 100, default: 50 }, + + // 기본 필터 필드들 + itemCode: { type: 'string', description: 'Item code filter' }, + itemName: { type: 'string', description: 'Item name filter' }, + description: { type: 'string', description: 'Description filter' }, + parentItemCode: { type: 'string', description: 'Parent item code filter' }, + itemLevel: { type: 'number', description: 'Item level filter', default: 5 }, + deleteFlag: { type: 'string', description: 'Delete flag filter' }, + unitOfMeasure: { type: 'string', description: 'Unit of measure filter' }, + steelType: { type: 'string', description: 'Steel type filter' }, + gradeMaterial: { type: 'string', description: 'Grade material filter' }, + changeDate: { type: 'string', description: 'Change date filter' }, + baseUnitOfMeasure: { type: 'string', description: 'Base unit of measure filter' }, + + // 고급 필터링 + search: { type: 'string', description: 'Global search across all fields' }, + filters: { type: 'json', description: 'Advanced filters array' }, + joinOperator: { type: 'string', enum: ['and', 'or'], default: 'and' }, + + // 정렬 + sort: { type: 'json', description: 'Sort configuration array' }, + + // 플래그 + flags: { type: 'json', description: 'Feature flags array' }, + }, + + // 사용 예시 + examples: { + basic: '/api/table/items/infinite?limit=50', + withSearch: '/api/table/items/infinite?limit=50&search=steel', + withFilters: '/api/table/items/infinite?limit=50&itemCode=ABC&steelType=carbon', + withCursor: '/api/table/items/infinite?cursor=123&limit=50', + withSort: '/api/table/items/infinite?limit=50&sort=[{"id":"itemName","desc":false}]', + advanced: '/api/table/items/infinite?limit=50&search=test&filters=[{"id":"itemLevel","operator":"eq","value":"1"}]&joinOperator=and&sort=[{"id":"createdAt","desc":true}]' + }, + + apiVersion: '1.0', + documentation: '/docs/api/items/infinite', + compatibility: { + searchParamsCache: 'full', + dataTable: 'full', + advancedFilters: 'full', + } + }); +} \ No newline at end of file -- cgit v1.2.3