diff options
Diffstat (limited to 'app')
7 files changed, 565 insertions, 27 deletions
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<SearchParams> } @@ -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 ( <Shell className="gap-2"> @@ -37,25 +41,21 @@ export default async function IndexPage(props: IndexPageProps) { Package Items </h2> <p className="text-muted-foreground"> - Item을 등록하고 관리할 수 있습니다.{" "} - {/* <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} + {/* Item을 등록하고 관리할 수 있습니다. */} + {isInfiniteMode && ( + <span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded"> + 무한 스크롤 모드 + </span> + )} </p> </div> </div> </div> <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} + {/* DateRangePicker 등 추가 컴포넌트 */} </React.Suspense> + <React.Suspense fallback={ <DataTableSkeleton @@ -67,8 +67,9 @@ export default async function IndexPage(props: IndexPageProps) { /> } > + {/* 통합된 ItemsTable 컴포넌트 사용 */} <ItemsTable promises={promises} /> </React.Suspense> </Shell> ) -} +}
\ 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<SearchParams> +} + +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 ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Commercial Bid Evaluation + </h2> + <p className="text-sm text-muted-foreground"> + CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <CbeVendorTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} 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<SearchParams> +} + +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 ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + RFQ + </h2> + <p className="text-muted-foreground"> + RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. + </p> + </div> + </div> + + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> + <p className="mb-6 text-muted-foreground"> + RFQ를 확인하려면 먼저 로그인하세요. + </p> + <Button size="lg" asChild> + <Link href="/partners"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ) + } + + // 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 ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + RFQ + </h2> + </div> + </div> + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> + <p className="mb-6 text-muted-foreground"> + 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요. + </p> + </div> + </div> + </Shell> + ) + } + + // If we got here, we have a valid vendor ID + const promises = Promise.all([ + getRfqResponsesForVendor({ + ...search, + filters: validFilters, + }, idAsNumber) + ]) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + RFQ + </h2> + <p className="text-muted-foreground"> + RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* DateRangePicker can go here */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <RfqsVendorTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ 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<SearchParams> +} + +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 ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Technical Bid Evaluation + </h2> + <p className="text-sm text-muted-foreground"> + TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <TbeVendorTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} 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} // 모드 전달 /> </div> 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 |
