summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/items/page.tsx47
-rw-r--r--app/[lng]/evcp/(evcp)/rfq-tech/[id]/layout.tsx6
-rw-r--r--app/[lng]/partners/(partners)/cbe-tech/page.tsx86
-rw-r--r--app/[lng]/partners/(partners)/rfq-tech/page.tsx133
-rw-r--r--app/[lng]/partners/(partners)/tbe-tech/page.tsx85
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx3
-rw-r--r--app/api/table/items/infinite/route.ts232
-rw-r--r--components/data-table/data-table-pagination.tsx219
-rw-r--r--components/data-table/infinite-data-table.tsx294
-rw-r--r--components/form-data/form-data-table-columns.tsx47
-rw-r--r--components/form-data/form-data-table.tsx20
-rw-r--r--components/form-data/import-excel-form.tsx117
-rw-r--r--config/menuConfig.ts16
-rw-r--r--db/schema/vendorData.ts24
-rw-r--r--hooks/use-data-table copy.ts334
-rw-r--r--hooks/use-data-table.ts315
-rw-r--r--lib/forms/services.ts140
-rw-r--r--lib/items/service.ts201
-rw-r--r--lib/items/table/items-table.tsx161
-rw-r--r--lib/items/validations.ts6
-rw-r--r--lib/rfqs-tech/table/rfqs-table-columns.tsx2
-rw-r--r--lib/sedp/sync-object-class.ts248
-rw-r--r--lib/tbe-tech/table/tbe-table-columns.tsx2
-rw-r--r--lib/tbe-tech/table/tbe-table.tsx7
-rw-r--r--lib/tech-vendor-rfq-response/service.ts467
-rw-r--r--lib/tech-vendor-rfq-response/types.ts76
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx365
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx272
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx323
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx427
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx79
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx62
-rw-r--r--lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx86
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx127
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx106
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx320
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx108
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx424
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx40
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx280
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx348
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx75
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx350
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx191
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx354
-rw-r--r--package-lock.json33
-rw-r--r--package.json3
47 files changed, 7303 insertions, 358 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
diff --git a/components/data-table/data-table-pagination.tsx b/components/data-table/data-table-pagination.tsx
index 4ed63a1b..922dacf1 100644
--- a/components/data-table/data-table-pagination.tsx
+++ b/components/data-table/data-table-pagination.tsx
@@ -7,6 +7,7 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
+ Infinity,
} from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -21,57 +22,99 @@ import {
interface DataTablePaginationProps<TData> {
table: Table<TData>
pageSizeOptions?: Array<number | "All">
+ // 무한 스크롤 관련 props
+ infiniteScroll?: {
+ enabled: boolean
+ hasNextPage: boolean
+ isLoadingMore: boolean
+ totalCount?: number | null
+ onLoadMore?: () => void
+ }
+ // 페이지 크기 변경 콜백 (필수!)
+ onPageSizeChange?: (pageSize: number) => void
}
export function DataTablePagination<TData>({
table,
pageSizeOptions = [10, 20, 30, 40, 50, "All"],
+ infiniteScroll,
+ onPageSizeChange,
}: DataTablePaginationProps<TData>) {
// 현재 테이블 pageSize
const currentPageSize = table.getState().pagination.pageSize
+ const isInfiniteMode = infiniteScroll?.enabled || currentPageSize >= 1_000_000
- // "All"을 1,000,000으로 처리할 것이므로,
- // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시
- const selectValue =
- currentPageSize === 1_000_000
- ? "All"
- : String(currentPageSize)
+ // "All"을 1,000,000으로 처리하고, 무한 스크롤 모드 표시
+ const selectValue = isInfiniteMode ? "All" : String(currentPageSize)
+
+ const handlePageSizeChange = (value: string) => {
+ if (!onPageSizeChange) {
+ console.warn('DataTablePagination: onPageSizeChange prop is required for page size changes to work')
+ return
+ }
+
+ if (value === "All") {
+ // "All" 선택 시 무한 스크롤 모드로 전환
+ onPageSizeChange(1_000_000) // URL 상태 업데이트만 수행
+ } else {
+ const newSize = Number(value)
+ onPageSizeChange(newSize) // URL 상태 업데이트만 수행
+ }
+
+ // table.setPageSize()는 호출하지 않음!
+ // URL 상태 변경이 테이블 상태로 자동 반영됨
+ }
return (
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
+ {/* 선택된 행 및 총 개수 정보 */}
<div className="flex-1 whitespace-nowrap text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
- {table.getFilteredRowModel().rows.length} row(s) selected.
- <span className="ml-4">Total: {table.getRowCount()} records</span>
+ {isInfiniteMode ? (
+ // 무한 스크롤 모드일 때
+ <>
+ {table.getRowModel().rows.length} row(s) selected.
+ {infiniteScroll?.totalCount !== null && (
+ <span className="ml-4">
+ Total: {infiniteScroll.totalCount?.toLocaleString()} records
+ <span className="ml-2 text-xs">
+ ({table.getRowModel().rows.length.toLocaleString()} loaded)
+ </span>
+ </span>
+ )}
+ </>
+ ) : (
+ // 페이지네이션 모드일 때
+ <>
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+ <span className="ml-4">Total: {table.getRowCount()} records</span>
+ </>
+ )}
</div>
+
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
{/* Rows per page Select */}
<div className="flex items-center space-x-2">
- <p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
- <Select
- value={selectValue}
- onValueChange={(value) => {
- if (value === "All") {
- // "All"을 1,000,000으로 치환
- table.setPageSize(1_000_000)
- } else {
- table.setPageSize(Number(value))
- }
- }}
- >
+ <p className="whitespace-nowrap text-sm font-medium">
+ {isInfiniteMode ? "View mode" : "Rows per page"}
+ </p>
+ <Select value={selectValue} onValueChange={handlePageSizeChange}>
<SelectTrigger className="h-8 w-[4.5rem]">
<SelectValue placeholder={selectValue} />
</SelectTrigger>
<SelectContent side="top">
{pageSizeOptions.map((option) => {
- // 화면에 표시할 라벨
const label = option === "All" ? "All" : String(option)
- // value도 문자열화
const val = option === "All" ? "All" : String(option)
return (
<SelectItem key={val} value={val}>
- {label}
+ <div className="flex items-center space-x-2">
+ {option === "All" && (
+ <Infinity className="h-3 w-3 text-muted-foreground" />
+ )}
+ <span>{label}</span>
+ </div>
</SelectItem>
)
})}
@@ -79,54 +122,90 @@ export function DataTablePagination<TData>({
</Select>
</div>
- {/* 현재 페이지 / 전체 페이지 */}
- <div className="flex items-center justify-center text-sm font-medium">
- Page {table.getState().pagination.pageIndex + 1} of{" "}
- {table.getPageCount()}
- </div>
+ {/* 페이지네이션 모드일 때만 페이지 정보 표시 */}
+ {!isInfiniteMode && (
+ <>
+ {/* 현재 페이지 / 전체 페이지 */}
+ <div className="flex items-center justify-center text-sm font-medium">
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
+ {table.getPageCount()}
+ </div>
- {/* 페이지 이동 버튼 */}
- <div className="flex items-center space-x-2">
- <Button
- aria-label="Go to first page"
- variant="outline"
- className="hidden size-8 p-0 lg:flex"
- onClick={() => table.setPageIndex(0)}
- disabled={!table.getCanPreviousPage()}
- >
- <ChevronsLeft className="size-4" aria-hidden="true" />
- </Button>
- <Button
- aria-label="Go to previous page"
- variant="outline"
- size="icon"
- className="size-8"
- onClick={() => table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- >
- <ChevronLeft className="size-4" aria-hidden="true" />
- </Button>
- <Button
- aria-label="Go to next page"
- variant="outline"
- size="icon"
- className="size-8"
- onClick={() => table.nextPage()}
- disabled={!table.getCanNextPage()}
- >
- <ChevronRight className="size-4" aria-hidden="true" />
- </Button>
- <Button
- aria-label="Go to last page"
- variant="outline"
- size="icon"
- className="hidden size-8 lg:flex"
- onClick={() => table.setPageIndex(table.getPageCount() - 1)}
- disabled={!table.getCanNextPage()}
- >
- <ChevronsRight className="size-4" aria-hidden="true" />
- </Button>
- </div>
+ {/* 페이지 이동 버튼 */}
+ <div className="flex items-center space-x-2">
+ <Button
+ aria-label="Go to first page"
+ variant="outline"
+ className="hidden size-8 p-0 lg:flex"
+ onClick={() => table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <ChevronsLeft className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to previous page"
+ variant="outline"
+ size="icon"
+ className="size-8"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <ChevronLeft className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to next page"
+ variant="outline"
+ size="icon"
+ className="size-8"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ <ChevronRight className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to last page"
+ variant="outline"
+ size="icon"
+ className="hidden size-8 lg:flex"
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ <ChevronsRight className="size-4" aria-hidden="true" />
+ </Button>
+ </div>
+ </>
+ )}
+
+ {/* 무한 스크롤 모드일 때 로드 더 버튼 */}
+ {isInfiniteMode && infiniteScroll && (
+ <div className="flex items-center space-x-2">
+ {infiniteScroll.hasNextPage && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={infiniteScroll.onLoadMore}
+ disabled={infiniteScroll.isLoadingMore}
+ >
+ {infiniteScroll.isLoadingMore ? (
+ <>
+ <div className="mr-2 h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
+ Loading...
+ </>
+ ) : (
+ <>
+ <ChevronRight className="mr-2 h-3 w-3" />
+ Load More
+ </>
+ )}
+ </Button>
+ )}
+ {!infiniteScroll.hasNextPage && table.getRowModel().rows.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ All data loaded
+ </span>
+ )}
+ </div>
+ )}
</div>
</div>
)
diff --git a/components/data-table/infinite-data-table.tsx b/components/data-table/infinite-data-table.tsx
new file mode 100644
index 00000000..fcac56ee
--- /dev/null
+++ b/components/data-table/infinite-data-table.tsx
@@ -0,0 +1,294 @@
+"use client"
+
+import * as React from "react"
+import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"
+import { ChevronRight, ChevronUp, Loader2 } from "lucide-react"
+import { useIntersection } from "@mantine/hooks"
+
+import { cn } from "@/lib/utils"
+import { getCommonPinningStyles } from "@/lib/data-table"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import { DataTableResizer } from "@/components/data-table/data-table-resizer"
+import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns"
+
+interface InfiniteDataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
+ table: TanstackTable<TData>
+ floatingBar?: React.ReactNode | null
+ autoSizeColumns?: boolean
+ compact?: boolean
+ // 무한 스크롤 관련 props
+ hasNextPage?: boolean
+ isLoadingMore?: boolean
+ onLoadMore?: () => void
+ totalCount?: number | null
+ isEmpty?: boolean
+}
+
+/**
+ * 무한 스크롤 지원 DataTable
+ */
+export function InfiniteDataTable<TData>({
+ table,
+ floatingBar = null,
+ autoSizeColumns = true,
+ compact = false,
+ hasNextPage = false,
+ isLoadingMore = false,
+ onLoadMore,
+ totalCount = null,
+ isEmpty = false,
+ children,
+ className,
+ maxHeight,
+ ...props
+}: InfiniteDataTableProps<TData> & { maxHeight?: string | number }) {
+
+ useAutoSizeColumns(table, autoSizeColumns)
+
+ // Intersection Observer for infinite scroll
+ const { ref: loadMoreRef, entry } = useIntersection({
+ threshold: 0.1,
+ })
+
+ // 자동 로딩 트리거
+ React.useEffect(() => {
+ if (entry?.isIntersecting && hasNextPage && !isLoadingMore && onLoadMore) {
+ onLoadMore()
+ }
+ }, [entry?.isIntersecting, hasNextPage, isLoadingMore, onLoadMore])
+
+ // 컴팩트 모드를 위한 클래스 정의
+ const compactStyles = compact ? {
+ row: "h-7",
+ cell: "py-1 px-2 text-sm",
+ groupRow: "py-1 bg-muted/20 text-sm",
+ emptyRow: "h-16",
+ } : {
+ row: "",
+ cell: "",
+ groupRow: "bg-muted/20",
+ emptyRow: "h-24",
+ }
+
+ return (
+ <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}>
+ {children}
+
+ {/* 총 개수 표시 */}
+ {totalCount !== null && (
+ <div className="text-sm text-muted-foreground">
+ 총 {totalCount.toLocaleString()}개 항목
+ {table.getRowModel().rows.length > 0 && (
+ <span className="ml-2">
+ (현재 {table.getRowModel().rows.length.toLocaleString()}개 로드됨)
+ </span>
+ )}
+ </div>
+ )}
+
+ <div
+ className="max-w-[100vw] overflow-auto"
+ style={{ maxHeight: maxHeight || '35rem' }}
+ >
+ <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
+ {/* 테이블 헤더 */}
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}>
+ {headerGroup.headers.map((header) => {
+ if (header.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <TableHead
+ key={header.id}
+ colSpan={header.colSpan}
+ data-column-id={header.column.id}
+ className={compact ? "py-1 px-2 text-sm" : ""}
+ style={{
+ ...getCommonPinningStyles({ column: header.column }),
+ width: header.getSize(),
+ }}
+ >
+ <div style={{ position: "relative" }}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ {header.column.getCanResize() && (
+ <DataTableResizer header={header} />
+ )}
+ </div>
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+
+ {/* 테이블 바디 */}
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ <>
+ {table.getRowModel().rows.map((row) => {
+ // 그룹핑 헤더 Row
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? ""
+ const groupingColumn = table.getColumn(groupingColumnId)
+
+ let columnLabel = groupingColumnId
+ if (groupingColumn) {
+ const headerDef = groupingColumn.columnDef.meta?.excelHeader
+ if (typeof headerDef === "string") {
+ columnLabel = headerDef
+ }
+ }
+
+ return (
+ <TableRow
+ key={row.id}
+ className={compactStyles.groupRow}
+ data-state={row.getIsExpanded() && "expanded"}
+ >
+ <TableCell
+ colSpan={table.getVisibleFlatColumns().length}
+ className={compact ? "py-1 px-2" : ""}
+ >
+ {row.getCanExpand() && (
+ <button
+ onClick={row.getToggleExpandedHandler()}
+ className="inline-flex items-center justify-center mr-2 w-5 h-5"
+ style={{
+ marginLeft: `${row.depth * 1.5}rem`,
+ }}
+ >
+ {row.getIsExpanded() ? (
+ <ChevronUp size={compact ? 14 : 16} />
+ ) : (
+ <ChevronRight size={compact ? 14 : 16} />
+ )}
+ </button>
+ )}
+
+ <span className="font-semibold">
+ {columnLabel}: {row.getValue(groupingColumnId)}
+ </span>
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({row.subRows.length} rows)
+ </span>
+ </TableCell>
+ </TableRow>
+ )
+ }
+
+ // 일반 Row
+ return (
+ <TableRow
+ key={row.id}
+ className={compactStyles.row}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => {
+ if (cell.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <TableCell
+ key={cell.id}
+ data-column-id={cell.column.id}
+ className={compactStyles.cell}
+ style={{
+ ...getCommonPinningStyles({ column: cell.column }),
+ width: cell.column.getSize(),
+ }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ )
+ })}
+ </TableRow>
+ )
+ })}
+ </>
+ ) : isEmpty ? (
+ // 데이터가 없을 때
+ <TableRow>
+ <TableCell
+ colSpan={table.getAllColumns().length}
+ className={compactStyles.emptyRow + " text-center"}
+ >
+ No results.
+ </TableCell>
+ </TableRow>
+ ) : null}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 무한 스크롤 로딩 영역 */}
+ <div className="flex flex-col items-center space-y-4 py-4">
+ {hasNextPage && (
+ <>
+ {/* Intersection Observer 타겟 */}
+ <div ref={loadMoreRef} className="h-1" />
+
+ {isLoadingMore && (
+ <div className="flex items-center space-x-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm text-muted-foreground">
+ 로딩 중...
+ </span>
+ </div>
+ )}
+
+ {/* 수동 로드 버튼 (자동 로딩 실패 시 대안) */}
+ {!isLoadingMore && onLoadMore && (
+ <Button
+ variant="outline"
+ onClick={onLoadMore}
+ className="w-full max-w-md"
+ >
+ 더 보기
+ </Button>
+ )}
+ </>
+ )}
+
+ {!hasNextPage && table.getRowModel().rows.length > 0 && (
+ <p className="text-sm text-muted-foreground">
+ 모든 데이터를 불러왔습니다.
+ </p>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2.5">
+ {/* 선택된 행 정보 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getRowModel().rows.length} row(s) selected.
+ </div>
+ )}
+
+ {/* Floating Bar (선택된 행 있을 때) */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx
index de479efb..b088276e 100644
--- a/components/form-data/form-data-table-columns.tsx
+++ b/components/form-data/form-data-table-columns.tsx
@@ -57,6 +57,7 @@ interface GetColumnsProps<TData> {
// 체크박스 선택 관련 props
selectedRows?: Record<string, boolean>;
onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void;
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
}
/**
@@ -72,6 +73,7 @@ export function getColumns<TData extends object>({
tempCount,
selectedRows = {},
onRowSelectionChange,
+ editableFieldsMap = new Map(), // 새로 추가
}: GetColumnsProps<TData>): ColumnDef<TData>[] {
const columns: ColumnDef<TData>[] = [];
@@ -139,42 +141,64 @@ export function getColumns<TData extends object>({
minWidth: 80,
paddingFactor: 1.2,
maxWidth: col.key === "TAG_NO" ? 120 : 150,
- isReadOnly: col.shi === true, // shi 정보를 메타데이터에 저장
+ isReadOnly: col.shi === true,
},
- // (3) 실제 셀(cell) 렌더링: type에 따라 분기 가능
+
cell: ({ row }) => {
const cellValue = row.getValue(col.key);
- // shi 속성이 true인 경우 적용할 스타일
- const isReadOnly = col.shi === true;
- const readOnlyClass = isReadOnly ? "read-only-cell" : "";
+ // 기본 읽기 전용 여부 (shi 속성 기반)
+ let isReadOnly = col.shi === true;
- // 읽기 전용 셀의 스타일 (인라인 스타일과 클래스 동시 적용)
+ // 동적 읽기 전용 여부 계산
+ if (!isReadOnly && col.key !== 'TAG_NO' && col.key !== 'TAG_DESC') {
+ const tagNo = row.getValue('TAG_NO') as string;
+ if (tagNo && editableFieldsMap.has(tagNo)) {
+ const editableFields = editableFieldsMap.get(tagNo) || [];
+ // 해당 TAG의 편집 가능 필드 목록에 없으면 읽기 전용
+ isReadOnly = !editableFields.includes(col.key);
+ } else {
+ // TAG_NO가 없거나 editableFieldsMap에 없으면 읽기 전용
+ isReadOnly = true;
+ }
+ }
+
+ const readOnlyClass = isReadOnly ? "read-only-cell" : "";
const cellStyle = isReadOnly
? { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' }
: {};
+ // 툴팁 메시지 설정
+ let tooltipMessage = "";
+ if (isReadOnly) {
+ if (col.shi === true) {
+ tooltipMessage = "SHI 전용 필드입니다";
+ } else if (col.key === 'TAG_NO' || col.key === 'TAG_DESC') {
+ tooltipMessage = "기본 필드는 수정할 수 없습니다";
+ } else {
+ tooltipMessage = "이 TAG 클래스에서는 편집할 수 없는 필드입니다";
+ }
+ }
+
// 데이터 타입별 처리
switch (col.type) {
case "NUMBER":
- // 예: number인 경우 콤마 등 표시
return (
<div
className={readOnlyClass}
style={cellStyle}
- title={isReadOnly ? "읽기 전용 필드입니다" : ""}
+ title={tooltipMessage}
>
{cellValue ? Number(cellValue).toLocaleString() : ""}
</div>
);
case "LIST":
- // 예: select인 경우 label만 표시
return (
<div
className={readOnlyClass}
style={cellStyle}
- title={isReadOnly ? "읽기 전용 필드입니다" : ""}
+ title={tooltipMessage}
>
{String(cellValue ?? "")}
</div>
@@ -186,7 +210,7 @@ export function getColumns<TData extends object>({
<div
className={readOnlyClass}
style={cellStyle}
- title={isReadOnly ? "읽기 전용 필드입니다" : ""}
+ title={tooltipMessage}
>
{String(cellValue ?? "")}
</div>
@@ -196,7 +220,6 @@ export function getColumns<TData extends object>({
}));
columns.push(...baseColumns);
-
// (4) 액션 칼럼 - update 버튼 예시
const actionColumn: ColumnDef<TData> = {
id: "update",
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index 0a76e145..6de6dd0b 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -110,6 +110,7 @@ export interface DynamicTableProps {
formName?: string;
objectCode?: string;
mode: "IM" | "ENG"; // 모드 속성
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
}
export default function DynamicTable({
@@ -121,6 +122,7 @@ export default function DynamicTable({
projectId,
mode = "IM", // 기본값 설정
formName = `${formCode}`, // Default form name based on formCode
+ editableFieldsMap = new Map(), // 새로 추가
}: DynamicTableProps) {
const params = useParams();
const router = useRouter();
@@ -230,7 +232,8 @@ export default function DynamicTable({
setReportData,
tempCount,
selectedRows,
- onRowSelectionChange: setSelectedRows
+ onRowSelectionChange: setSelectedRows,
+ editableFieldsMap
}),
[columnsJSON, setRowAction, setReportData, tempCount, selectedRows]
);
@@ -397,26 +400,30 @@ export default function DynamicTable({
async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
-
+
try {
setIsImporting(true);
- // Call the updated importExcelData function with direct save capability
+ // Call the updated importExcelData function with editableFieldsMap
const result = await importExcelData({
file,
tableData,
columnsJSON,
- formCode, // Pass formCode for direct save
- contractItemId, // Pass contractItemId for direct save
+ formCode,
+ contractItemId,
+ editableFieldsMap, // 추가: 편집 가능 필드 정보 전달
onPendingChange: setIsImporting,
onDataUpdate: (newData) => {
- // This is called only after successful DB save
setTableData(Array.isArray(newData) ? newData : newData(tableData));
}
});
// If import and save was successful, refresh the page
if (result.success) {
+ // Show additional info about skipped fields if any
+ if (result.skippedFields && result.skippedFields.length > 0) {
+ console.log("Import completed with some fields skipped:", result.skippedFields);
+ }
router.refresh();
}
} catch (error) {
@@ -428,7 +435,6 @@ export default function DynamicTable({
setIsImporting(false);
}
}
-
// SEDP Send handler (with confirmation)
function handleSEDPSendClick() {
if (tableData.length === 0) {
diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx
index d425a909..f32e44d8 100644
--- a/components/form-data/import-excel-form.tsx
+++ b/components/form-data/import-excel-form.tsx
@@ -1,17 +1,18 @@
-// lib/excelUtils.ts (continued)
import ExcelJS from "exceljs";
import { saveAs } from "file-saver";
import { toast } from "sonner";
import { DataTableColumnJSON } from "./form-data-table-columns";
import { updateFormDataInDB } from "@/lib/forms/services";
import { decryptWithServerAction } from "../drm/drmUtils";
-// Assuming the previous types are defined above
+
+// Enhanced options interface with editableFieldsMap
export interface ImportExcelOptions {
file: File;
tableData: GenericData[];
columnsJSON: DataTableColumnJSON[];
- formCode?: string; // Optional - provide to enable direct DB save
- contractItemId?: number; // Optional - provide to enable direct DB save
+ formCode?: string;
+ contractItemId?: number;
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
onPendingChange?: (isPending: boolean) => void;
onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void;
}
@@ -21,6 +22,7 @@ export interface ImportExcelResult {
importedCount?: number;
error?: any;
message?: string;
+ skippedFields?: { tagNo: string, fields: string[] }[]; // 건너뛴 필드 정보
}
export interface ExportExcelOptions {
@@ -30,7 +32,6 @@ export interface ExportExcelOptions {
onPendingChange?: (isPending: boolean) => void;
}
-// For typing consistency
interface GenericData {
[key: string]: any;
}
@@ -41,6 +42,7 @@ export async function importExcelData({
columnsJSON,
formCode,
contractItemId,
+ editableFieldsMap = new Map(), // 기본값으로 빈 Map
onPendingChange,
onDataUpdate
}: ImportExcelOptions): Promise<ImportExcelResult> {
@@ -59,7 +61,6 @@ export async function importExcelData({
});
const workbook = new ExcelJS.Workbook();
- // const arrayBuffer = await file.arrayBuffer();
const arrayBuffer = await decryptWithServerAction(file);
await workbook.xlsx.load(arrayBuffer);
@@ -127,6 +128,7 @@ export async function importExcelData({
const importedData: GenericData[] = [];
const lastRowNumber = worksheet.lastRow?.number || 1;
let errorCount = 0;
+ const skippedFieldsLog: { tagNo: string, fields: string[] }[] = []; // 건너뛴 필드 로그
// Process each data row
for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) {
@@ -135,21 +137,51 @@ export async function importExcelData({
if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows
let errorMessage = "";
+ let warningMessage = "";
const rowObj: Record<string, any> = {};
+ const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들
- // Get the TAG_NO first to identify existing data
+ // Get the TAG_NO first to identify existing data and editable fields
const tagNoColIndex = keyToIndexMap.get("TAG_NO");
const tagNo = tagNoColIndex ? String(rowValues[tagNoColIndex] ?? "").trim() : "";
const existingRowData = existingDataMap.get(tagNo);
+
+ // Get editable fields for this specific TAG
+ const editableFields = editableFieldsMap.has(tagNo) ? editableFieldsMap.get(tagNo)! : [];
// Process each column
columnsJSON.forEach((col) => {
const colIndex = keyToIndexMap.get(col.key);
if (colIndex === undefined) return;
- // Check if this column should be ignored (col.shi === true)
+ // Determine if this field is editable
+ let isFieldEditable = true;
+ let skipReason = "";
+
+ // 1. Check if this is a SHI-only field
if (col.shi === true) {
- // Use existing value instead of Excel value
+ isFieldEditable = false;
+ skipReason = "SHI-only field";
+ }
+ // 2. Check if this field is editable based on TAG class attributes
+ else if (col.key !== "TAG_NO" && col.key !== "TAG_DESC") {
+ // For non-basic fields, check if they're in the editable list
+ if (tagNo && editableFieldsMap.has(tagNo)) {
+ if (!editableFields.includes(col.key)) {
+ isFieldEditable = false;
+ skipReason = "Not editable for this TAG class";
+ }
+ } else if (tagNo) {
+ // If TAG exists but no editable fields info, treat as not editable
+ isFieldEditable = false;
+ skipReason = "No editable fields info for this TAG";
+ }
+ }
+ // 3. TAG_NO and TAG_DESC are always considered basic fields
+ // (They should be editable, but you might want to add specific logic here)
+
+ // If field is not editable, use existing value or default
+ if (!isFieldEditable) {
if (existingRowData && existingRowData[col.key] !== undefined) {
rowObj[col.key] = existingRowData[col.key];
} else {
@@ -165,9 +197,13 @@ export async function importExcelData({
break;
}
}
+
+ // Log skipped field
+ skippedFields.push(`${col.label} (${skipReason})`);
return; // Skip processing Excel value for this column
}
+ // Process Excel value for editable fields
const cellValue = rowValues[colIndex] ?? "";
let stringVal = String(cellValue).trim();
@@ -212,6 +248,15 @@ export async function importExcelData({
}
});
+ // Log skipped fields for this TAG
+ if (skippedFields.length > 0) {
+ skippedFieldsLog.push({
+ tagNo: tagNo,
+ fields: skippedFields
+ });
+ warningMessage += `Skipped ${skippedFields.length} non-editable fields. `;
+ }
+
// Validate TAG_NO
const tagNum = rowObj["TAG_NO"];
if (!tagNum) {
@@ -225,10 +270,23 @@ export async function importExcelData({
row.getCell(lastColIndex).value = errorMessage.trim();
errorCount++;
} else {
+ // Add warning message to Excel if there are skipped fields
+ if (warningMessage) {
+ row.getCell(lastColIndex).value = `WARNING: ${warningMessage.trim()}`;
+ }
importedData.push(rowObj);
}
}
+ // Show summary of skipped fields
+ if (skippedFieldsLog.length > 0) {
+ const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0);
+ console.log("Skipped fields summary:", skippedFieldsLog);
+ toast.info(
+ `${totalSkippedFields} non-editable fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.`
+ );
+ }
+
// If there are validation errors, download error report and exit
if (errorCount > 0) {
const outBuffer = await workbook.xlsx.writeBuffer();
@@ -236,7 +294,11 @@ export async function importExcelData({
toast.error(
`There are ${errorCount} error row(s). Please check downloaded file.`
);
- return { success: false, error: "Data validation errors" };
+ return {
+ success: false,
+ error: "Data validation errors",
+ skippedFields: skippedFieldsLog
+ };
}
// If we reached here, all data is valid
@@ -310,13 +372,15 @@ export async function importExcelData({
return {
success: true,
importedCount: successCount,
- message: `Partially successful: ${successCount} rows updated, ${errorCount} errors`
+ message: `Partially successful: ${successCount} rows updated, ${errorCount} errors`,
+ skippedFields: skippedFieldsLog
};
} else {
return {
success: false,
error: "All updates failed",
- message: errors.join("\n")
+ message: errors.join("\n"),
+ skippedFields: skippedFieldsLog
};
}
}
@@ -326,16 +390,25 @@ export async function importExcelData({
onDataUpdate(() => mergedData);
}
- toast.success(`Successfully updated ${successCount} rows`);
+ const successMessage = skippedFieldsLog.length > 0
+ ? `Successfully updated ${successCount} rows (some non-editable fields were preserved)`
+ : `Successfully updated ${successCount} rows`;
+
+ toast.success(successMessage);
return {
success: true,
importedCount: successCount,
- message: "All data imported and saved to database"
+ message: "All data imported and saved to database",
+ skippedFields: skippedFieldsLog
};
} catch (saveError) {
console.error("Failed to save imported data:", saveError);
toast.error("Failed to save imported data to database");
- return { success: false, error: saveError };
+ return {
+ success: false,
+ error: saveError,
+ skippedFields: skippedFieldsLog
+ };
}
} else {
// Fall back to just updating local state if DB parameters aren't provided
@@ -343,8 +416,16 @@ export async function importExcelData({
onDataUpdate(() => mergedData);
}
- toast.success(`Imported ${importedData.length} rows successfully (local only)`);
- return { success: true, importedCount: importedData.length };
+ const successMessage = skippedFieldsLog.length > 0
+ ? `Imported ${importedData.length} rows successfully (some fields preserved)`
+ : `Imported ${importedData.length} rows successfully`;
+
+ toast.success(`${successMessage} (local only)`);
+ return {
+ success: true,
+ importedCount: importedData.length,
+ skippedFields: skippedFieldsLog
+ };
}
} catch (err) {
@@ -354,4 +435,4 @@ export async function importExcelData({
} finally {
if (onPendingChange) onPendingChange(false);
}
-} \ No newline at end of file
+}
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index eb6913c3..8dadb5a7 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -318,15 +318,21 @@ export const mainNavVendor: MenuSection[] = [
group: "기술영업"
},
{
- title: "기술영업 - 해양 Hull RFQ",
- href: `/partners/techsales/rfq-offshore-hull`,
+ title: "기술영업 - 해양 RFQ",
+ href: `/partners/rfq-tech`,
description: "견적 요청에 대한 응답 작성",
group: "기술영업"
},
{
- title: "기술영업 - 해양 Top RFQ",
- href: `/partners/techsales/rfq-offshore-top`,
- description: "견적 요청에 대한 응답 작성",
+ title: "기술영업 - 해양 TBE",
+ href: `/partners/tbe-tech`,
+ description: "TBE 요청에 대한 응답 작성",
+ group: "기술영업"
+ },
+ {
+ title: "기술영업 - 해양 CBE",
+ href: `/partners/cbe-tech`,
+ description: "CBE 요청에 대한 응답 작성",
group: "기술영업"
},
{
diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts
index 20025dc0..16ae87d9 100644
--- a/db/schema/vendorData.ts
+++ b/db/schema/vendorData.ts
@@ -11,7 +11,7 @@ import {
primaryKey,
foreignKey,
pgView,
- boolean
+ boolean, index
} from "drizzle-orm/pg-core"
import { relations, and, eq, sql} from "drizzle-orm";
@@ -199,6 +199,28 @@ export const tagClasses = pgTable("tag_classes", {
};
})
+export const tagClassAttributes = pgTable("tag_class_attributes", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ tagClassId: integer("tag_class_id")
+ .notNull()
+ .references(() => tagClasses.id, { onDelete: "cascade" }),
+ attId: varchar("att_id", { length: 50 }).notNull(),
+ defVal: text("def_val"),
+ uomId: varchar("uom_id", { length: 50 }),
+ seq: integer("seq").default(0),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+}, (table) => {
+ return {
+ // 같은 tagClass 내에서 attId는 유니크해야 함
+ uniqAttIdInTagClass: unique("uniq_att_id_in_tag_class").on(
+ table.tagClassId,
+ table.attId
+ ),
+ // 순서 인덱스
+ seqIdx: index("tag_class_attributes_seq_idx").on(table.seq)
+ };
+});
// tagTypeClassFormMappings에 projectId 추가
export const tagTypeClassFormMappings = pgTable("tag_type_class_form_mappings", {
id: serial("id").primaryKey(),
diff --git a/hooks/use-data-table copy.ts b/hooks/use-data-table copy.ts
new file mode 100644
index 00000000..a3301067
--- /dev/null
+++ b/hooks/use-data-table copy.ts
@@ -0,0 +1,334 @@
+"use client"
+
+import * as React from "react"
+import type { DataTableFilterField, ExtendedSortingState } from "@/types/table"
+import {
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ useReactTable,
+ type ColumnFiltersState,
+ type PaginationState,
+ type RowSelectionState,
+ type SortingState,
+ type TableOptions,
+ type TableState,
+ type Updater,
+ type VisibilityState,
+ type ExpandedState,
+} from "@tanstack/react-table"
+import {
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ useQueryState,
+ useQueryStates,
+ type Parser,
+ type UseQueryStateOptions,
+} from "nuqs"
+
+import { getSortingStateParser } from "@/lib/parsers"
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+
+interface UseDataTableProps<TData>
+ extends Omit<
+ TableOptions<TData>,
+ | "state"
+ | "pageCount"
+ | "getCoreRowModel"
+ | "manualFiltering"
+ | "manualPagination"
+ | "manualSorting"
+ | "onGroupingChange"
+ | "onExpandedChange"
+ | "getExpandedRowModel"
+ >,
+ Required<Pick<TableOptions<TData>, "pageCount">> {
+ filterFields?: DataTableFilterField<TData>[]
+ enableAdvancedFilter?: boolean
+ history?: "push" | "replace"
+ scroll?: boolean
+ shallow?: boolean
+ throttleMs?: number
+ debounceMs?: number
+ startTransition?: React.TransitionStartFunction
+ clearOnDefault?: boolean
+ initialState?: Omit<Partial<TableState>, "sorting"> & {
+ sorting?: ExtendedSortingState<TData>
+ /**
+ * 기본 그룹핑 컬럼 배열 (멀티 그룹핑)
+ */
+ grouping?: string[]
+ /**
+ * 그룹 확장/접기 상태
+ */
+ expanded?: Record<string, boolean>
+ }
+}
+
+export function useDataTable<TData>({
+ pageCount = -1,
+ filterFields = [],
+ enableAdvancedFilter = false,
+ history = "replace",
+ scroll = false,
+ shallow = true,
+ throttleMs = 50,
+ debounceMs = 300,
+ clearOnDefault = false,
+ startTransition,
+ initialState,
+ ...props
+}: UseDataTableProps<TData>) {
+ // 공통 URL QueryState 옵션
+ const queryStateOptions = React.useMemo<
+ Omit<UseQueryStateOptions<string>, "parse">
+ >(() => {
+ return {
+ history,
+ scroll,
+ shallow,
+ throttleMs,
+ debounceMs,
+ clearOnDefault,
+ startTransition,
+ }
+ }, [
+ history,
+ scroll,
+ shallow,
+ throttleMs,
+ debounceMs,
+ clearOnDefault,
+ startTransition,
+ ])
+
+ // -------- RowSelection & ColumnVisibility는 URL 동기화 없이 로컬 상태 ----------
+ const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
+ initialState?.rowSelection ?? {}
+ )
+ const [columnVisibility, setColumnVisibility] =
+ React.useState<VisibilityState>(initialState?.columnVisibility ?? {})
+
+ // -------- Pagination (page, perPage) URL 동기화 --------
+ const [page, setPage] = useQueryState(
+ "page",
+ parseAsInteger.withOptions(queryStateOptions).withDefault(1)
+ )
+ const [perPage, setPerPage] = useQueryState(
+ "perPage",
+ parseAsInteger
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.pagination?.pageSize ?? 10)
+ )
+
+ // -------- Sorting (sort) URL 동기화 --------
+ const [sorting, setSorting] = useQueryState(
+ "sort",
+ getSortingStateParser<TData>()
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.sorting ?? [])
+ )
+ function onSortingChange(updaterOrValue: Updater<SortingState>) {
+ if (typeof updaterOrValue === "function") {
+ const newSorting = updaterOrValue(sorting) as ExtendedSortingState<TData>
+ void setSorting(newSorting)
+ } else {
+ void setSorting(updaterOrValue as ExtendedSortingState<TData>)
+ }
+ }
+
+ // -------- Grouping (group) URL 동기화 (멀티 컬럼) --------
+ const [grouping, setGrouping] = useQueryState(
+ "group",
+ parseAsArrayOf(parseAsString, ",")
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.grouping ?? [])
+ )
+ function onGroupingChange(updaterOrValue: Updater<string[]>) {
+ if (typeof updaterOrValue === "function") {
+ const newGrouping = updaterOrValue(grouping)
+ void setGrouping(newGrouping)
+ } else {
+ void setGrouping(updaterOrValue)
+ }
+ }
+
+ // -------- Group Expand/Collapse --------
+ const [expanded, setExpanded] = React.useState<ExpandedState>({}) // or true/false
+
+ function onExpandedChange(updater: Updater<ExpandedState>) {
+ setExpanded((old) => (typeof updater === "function" ? updater(old) : updater))
+ }
+
+ // -------- Filters (search/faceted) URL 동기화 --------
+ const filterParsers = React.useMemo(() => {
+ return filterFields.reduce<
+ Record<string, Parser<string> | Parser<string[]>>
+ >((acc, field) => {
+ if (field.options) {
+ // Faceted filter -> 여러 값 가능
+ acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions(
+ queryStateOptions
+ )
+ } else {
+ // Search filter -> 단일 값
+ acc[field.id] = parseAsString.withOptions(queryStateOptions)
+ }
+ return acc
+ }, {})
+ }, [filterFields, queryStateOptions])
+
+ const [filterValues, setFilterValues] = useQueryStates(filterParsers)
+ const debouncedSetFilterValues = useDebouncedCallback(
+ setFilterValues,
+ debounceMs
+ )
+
+ // -------- PaginationState 객체 --------
+ const pagination: PaginationState = {
+ pageIndex: page - 1,
+ pageSize: perPage,
+ }
+ function onPaginationChange(updaterOrValue: Updater<PaginationState>) {
+ if (typeof updaterOrValue === "function") {
+ const newPagination = updaterOrValue(pagination)
+ void setPage(newPagination.pageIndex + 1)
+ void setPerPage(newPagination.pageSize)
+ } else {
+ void setPage(updaterOrValue.pageIndex + 1)
+ void setPerPage(updaterOrValue.pageSize)
+ }
+ }
+
+ // -------- ColumnFiltersState --------
+ const initialColumnFilters: ColumnFiltersState = React.useMemo(() => {
+ // AdvancedFilter 모드가 아니면 URL에서 온 filterValues를 그대로 적용
+ return enableAdvancedFilter
+ ? []
+ : Object.entries(filterValues).reduce<ColumnFiltersState>(
+ (filters, [key, value]) => {
+ if (value !== null) {
+ filters.push({
+ id: key,
+ value: Array.isArray(value) ? value : [value],
+ })
+ }
+ return filters
+ },
+ []
+ )
+ }, [filterValues, enableAdvancedFilter])
+
+ const [columnFilters, setColumnFilters] =
+ React.useState<ColumnFiltersState>(initialColumnFilters)
+
+ // 검색용 / Facet 필터용 컬럼 구분
+ const { searchableColumns, filterableColumns } = React.useMemo(() => {
+ return enableAdvancedFilter
+ ? { searchableColumns: [], filterableColumns: [] }
+ : {
+ searchableColumns: filterFields.filter((field) => !field.options),
+ filterableColumns: filterFields.filter((field) => field.options),
+ }
+ }, [filterFields, enableAdvancedFilter])
+
+ const onColumnFiltersChange = React.useCallback(
+ (updaterOrValue: Updater<ColumnFiltersState>) => {
+ if (enableAdvancedFilter) return
+ setColumnFilters((prev) => {
+ const next =
+ typeof updaterOrValue === "function"
+ ? updaterOrValue(prev)
+ : updaterOrValue
+
+ const filterUpdates = next.reduce<
+ Record<string, string | string[] | null>
+ >((acc, filter) => {
+ if (searchableColumns.find((col) => col.id === filter.id)) {
+ acc[filter.id] = filter.value as string
+ } else if (filterableColumns.find((col) => col.id === filter.id)) {
+ acc[filter.id] = filter.value as string[]
+ }
+ return acc
+ }, {})
+
+ // 빠진 필터는 null로 설정
+ prev.forEach((prevFilter) => {
+ if (!next.some((filter) => filter.id === prevFilter.id)) {
+ filterUpdates[prevFilter.id] = null
+ }
+ })
+
+ // 필터가 바뀌면 첫 페이지로
+ void setPage(1)
+ debouncedSetFilterValues(filterUpdates)
+ return next
+ })
+ },
+ [
+ debouncedSetFilterValues,
+ enableAdvancedFilter,
+ filterableColumns,
+ searchableColumns,
+ setPage,
+ ]
+ )
+
+ // -------- TanStack Table 인스턴스 생성 --------
+ const table = useReactTable({
+ ...props,
+ initialState,
+ pageCount,
+ state: {
+ pagination,
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters: enableAdvancedFilter ? [] : columnFilters,
+ grouping,
+ expanded,
+ },
+
+ // 콜백들
+ onRowSelectionChange: setRowSelection,
+ onPaginationChange,
+ onSortingChange,
+ onColumnFiltersChange,
+ onColumnVisibilityChange: setColumnVisibility,
+ onGroupingChange,
+ onExpandedChange,
+
+ // 기본 모델
+ getCoreRowModel: getCoreRowModel(),
+ // 필터 (Advanced 모드 아니면 사용)
+ getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ // 그룹 + 확장
+ getGroupedRowModel: getGroupedRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+
+ // Faceted (Facet 필터용)
+ getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(),
+ getFacetedUniqueValues: enableAdvancedFilter
+ ? undefined
+ : getFacetedUniqueValues(),
+
+ // 서버 사이드 사용
+ manualPagination: true,
+ manualSorting: true,
+ manualFiltering: true,
+ // 그룹핑도 서버에서 처리한다면 별도 로직이 필요하지만,
+ // TanStack Table v8에는 manualGrouping 옵션은 없음
+ // (그룹핑을 서버에서 이미 해서 내려받는다면 row 구조 처리 필요)
+
+ })
+
+ return { table }
+} \ No newline at end of file
diff --git a/hooks/use-data-table.ts b/hooks/use-data-table.ts
index a3301067..f33c2e8b 100644
--- a/hooks/use-data-table.ts
+++ b/hooks/use-data-table.ts
@@ -1,3 +1,4 @@
+// hooks/use-data-table.ts (업데이트)
"use client"
import * as React from "react"
@@ -31,10 +32,21 @@ import {
type Parser,
type UseQueryStateOptions,
} from "nuqs"
+import useSWRInfinite from "swr/infinite"
import { getSortingStateParser } from "@/lib/parsers"
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+// 무한 스크롤 임계값 (이 값 이상이면 무한 스크롤 모드)
+const INFINITE_SCROLL_THRESHOLD = 1_000_000
+
+// 무한 스크롤 설정
+interface InfiniteScrollConfig {
+ apiEndpoint: string
+ tableName: string
+ maxPageSize?: number
+}
+
interface UseDataTableProps<TData>
extends Omit<
TableOptions<TData>,
@@ -60,15 +72,22 @@ interface UseDataTableProps<TData>
clearOnDefault?: boolean
initialState?: Omit<Partial<TableState>, "sorting"> & {
sorting?: ExtendedSortingState<TData>
- /**
- * 기본 그룹핑 컬럼 배열 (멀티 그룹핑)
- */
grouping?: string[]
- /**
- * 그룹 확장/접기 상태
- */
expanded?: Record<string, boolean>
}
+ // 기존 데이터 (페이지네이션 모드용)
+ data?: TData[]
+ // 무한 스크롤 설정 (pageSize 기반 자동 활성화)
+ infiniteScrollConfig?: InfiniteScrollConfig
+}
+
+// 무한 스크롤 응답 타입
+interface InfiniteScrollResponse<TData> {
+ mode: 'infinite'
+ data: TData[]
+ hasNextPage: boolean
+ nextCursor: string | null
+ total?: number | null
}
export function useDataTable<TData>({
@@ -83,8 +102,11 @@ export function useDataTable<TData>({
clearOnDefault = false,
startTransition,
initialState,
+ data: initialData = [],
+ infiniteScrollConfig,
...props
}: UseDataTableProps<TData>) {
+
// 공통 URL QueryState 옵션
const queryStateOptions = React.useMemo<
Omit<UseQueryStateOptions<string>, "parse">
@@ -108,14 +130,14 @@ export function useDataTable<TData>({
startTransition,
])
- // -------- RowSelection & ColumnVisibility는 URL 동기화 없이 로컬 상태 ----------
+ // -------- 기존 상태들 --------
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
initialState?.rowSelection ?? {}
)
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(initialState?.columnVisibility ?? {})
- // -------- Pagination (page, perPage) URL 동기화 --------
+ // -------- Pagination URL 동기화 --------
const [page, setPage] = useQueryState(
"page",
parseAsInteger.withOptions(queryStateOptions).withDefault(1)
@@ -127,13 +149,208 @@ export function useDataTable<TData>({
.withDefault(initialState?.pagination?.pageSize ?? 10)
)
- // -------- Sorting (sort) URL 동기화 --------
+ // pageSize 기반 무한 스크롤 모드 자동 결정
+ const isInfiniteMode = perPage >= INFINITE_SCROLL_THRESHOLD
+
+ // -------- Sorting URL 동기화 --------
const [sorting, setSorting] = useQueryState(
"sort",
getSortingStateParser<TData>()
.withOptions(queryStateOptions)
.withDefault(initialState?.sorting ?? [])
)
+
+ // -------- Advanced Filters URL 동기화 --------
+ const [filters, setFilters] = useQueryState(
+ "filters",
+ parseAsString.withOptions(queryStateOptions).withDefault("[]")
+ )
+
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "joinOperator",
+ parseAsString.withOptions(queryStateOptions).withDefault("and")
+ )
+
+ const [search, setSearch] = useQueryState(
+ "search",
+ parseAsString.withOptions(queryStateOptions).withDefault("")
+ )
+
+ // -------- Grouping URL 동기화 --------
+ const [grouping, setGrouping] = useQueryState(
+ "group",
+ parseAsArrayOf(parseAsString, ",")
+ .withOptions(queryStateOptions)
+ .withDefault(initialState?.grouping ?? [])
+ )
+
+ const [expanded, setExpanded] = React.useState<ExpandedState>({})
+
+ // -------- 무한 스크롤 SWR 설정 --------
+ const parsedFilters = React.useMemo(() => {
+ try {
+ return JSON.parse(filters)
+ } catch {
+ return []
+ }
+ }, [filters])
+
+ const sortForSWR = React.useMemo(() => {
+ return sorting.map(sort => ({
+ id: sort.id,
+ desc: sort.desc
+ }))
+ }, [sorting])
+
+ // 실제 페이지 크기 계산 (무한 스크롤 시)
+ const effectivePageSize = React.useMemo(() => {
+ if (!isInfiniteMode) return perPage
+
+ // 무한 스크롤 모드에서는 적절한 청크 크기 사용
+ const maxSize = infiniteScrollConfig?.maxPageSize || 100
+ return Math.min(50, maxSize) // 기본 50개씩 로드
+ }, [isInfiniteMode, perPage, infiniteScrollConfig?.maxPageSize])
+
+ // SWR 키 생성 함수
+ const getKey = React.useCallback(
+ (pageIndex: number, previousPageData: InfiniteScrollResponse<TData> | null) => {
+ if (!isInfiniteMode || !infiniteScrollConfig) return null
+
+ const params = new URLSearchParams()
+
+ if (pageIndex === 0) {
+ // 첫 페이지
+ params.set("limit", String(effectivePageSize))
+ if (search) params.set("search", search)
+ if (parsedFilters.length) params.set("filters", JSON.stringify(parsedFilters))
+ if (joinOperator !== "and") params.set("joinOperator", joinOperator)
+ if (sortForSWR.length) params.set("sort", JSON.stringify(sortForSWR))
+
+ return `${infiniteScrollConfig.apiEndpoint}?${params.toString()}`
+ }
+
+ // 다음 페이지
+ if (!previousPageData || !previousPageData.hasNextPage) return null
+
+ params.set("cursor", previousPageData.nextCursor || "")
+ params.set("limit", String(effectivePageSize))
+ if (search) params.set("search", search)
+ if (parsedFilters.length) params.set("filters", JSON.stringify(parsedFilters))
+ if (joinOperator !== "and") params.set("joinOperator", joinOperator)
+ if (sortForSWR.length) params.set("sort", JSON.stringify(sortForSWR))
+
+ return `${infiniteScrollConfig.apiEndpoint}?${params.toString()}`
+ },
+ [isInfiniteMode, infiniteScrollConfig, effectivePageSize, search, parsedFilters, joinOperator, sortForSWR]
+ )
+
+ // SWR Infinite 사용
+ const {
+ data: swrData,
+ error: swrError,
+ isLoading: swrIsLoading,
+ isValidating: swrIsValidating,
+ mutate: swrMutate,
+ size: swrSize,
+ setSize: swrSetSize,
+ } = useSWRInfinite<InfiniteScrollResponse<TData>>(
+ getKey,
+ async (url: string) => {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return response.json()
+ },
+ {
+ revalidateFirstPage: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ }
+ )
+
+ // 무한 스크롤 데이터 병합
+ const infiniteData = React.useMemo(() => {
+ if (!isInfiniteMode || !swrData) return []
+ return swrData.flatMap(page => page.data)
+ }, [swrData, isInfiniteMode])
+
+ // 무한 스크롤 메타 정보
+ const infiniteMeta = React.useMemo(() => {
+ if (!isInfiniteMode || !infiniteScrollConfig || !swrData) return null
+
+ const totalCount = swrData[0]?.total ?? null
+ const hasNextPage = swrData[swrData.length - 1]?.hasNextPage ?? false
+ const isLoadingMore = swrIsValidating && swrData && typeof swrData[swrSize - 1] !== "undefined"
+
+ return {
+ enabled: true,
+ totalCount,
+ hasNextPage,
+ isLoadingMore,
+ loadMore: () => {
+ if (hasNextPage && !isLoadingMore) {
+ swrSetSize(prev => prev + 1)
+ }
+ },
+ reset: () => {
+ swrSetSize(1)
+ swrMutate()
+ },
+ refresh: () => swrMutate(),
+ error: swrError,
+ isLoading: swrIsLoading,
+ isEmpty: swrData?.[0]?.data.length === 0,
+ }
+ }, [isInfiniteMode, infiniteScrollConfig, swrData, swrIsValidating, swrSize, swrError, swrIsLoading, swrSetSize, swrMutate])
+
+ // 검색어나 필터 변경 시 무한 스크롤 리셋
+ React.useEffect(() => {
+ if (isInfiniteMode && infiniteMeta) {
+ infiniteMeta.reset()
+ }
+ }, [search, parsedFilters, joinOperator, sortForSWR, isInfiniteMode])
+
+ // 최종 데이터 결정
+ const finalData = isInfiniteMode ? infiniteData : initialData
+
+ // pageSize 변경 핸들러
+ const handlePageSizeChange = React.useCallback((newPageSize: number) => {
+ // URL 상태 업데이트 (이게 핵심!)
+ void setPerPage(newPageSize)
+
+ // 모드 전환 시 첫 페이지로 리셋
+ const wasInfiniteMode = perPage >= INFINITE_SCROLL_THRESHOLD
+ const willBeInfiniteMode = newPageSize >= INFINITE_SCROLL_THRESHOLD
+
+ if (wasInfiniteMode !== willBeInfiniteMode) {
+ void setPage(1)
+
+ // 무한 스크롤에서 페이지네이션으로 전환 시 무한 스크롤 데이터 리셋
+ if (wasInfiniteMode && infiniteMeta) {
+ infiniteMeta.reset()
+ }
+ }
+ }, [setPerPage, setPage, perPage, infiniteMeta])
+
+ // 그리고 onPaginationChange 함수도 수정
+ function onPaginationChange(updaterOrValue: Updater<PaginationState>) {
+ if (isInfiniteMode) return // 무한 스크롤 모드에서는 페이지네이션 변경 무시
+
+ if (typeof updaterOrValue === "function") {
+ const newPagination = updaterOrValue(pagination)
+ void setPage(newPagination.pageIndex + 1)
+ // perPage 변경은 handlePageSizeChange를 통해서만!
+ if (newPagination.pageSize !== perPage) {
+ handlePageSizeChange(newPagination.pageSize)
+ }
+ } else {
+ void setPage(updaterOrValue.pageIndex + 1)
+ // perPage 변경은 handlePageSizeChange를 통해서만!
+ if (updaterOrValue.pageSize !== perPage) {
+ handlePageSizeChange(updaterOrValue.pageSize)
+ }
+ }
+ }
+ // -------- 나머지 기존 로직들 --------
function onSortingChange(updaterOrValue: Updater<SortingState>) {
if (typeof updaterOrValue === "function") {
const newSorting = updaterOrValue(sorting) as ExtendedSortingState<TData>
@@ -143,13 +360,6 @@ export function useDataTable<TData>({
}
}
- // -------- Grouping (group) URL 동기화 (멀티 컬럼) --------
- const [grouping, setGrouping] = useQueryState(
- "group",
- parseAsArrayOf(parseAsString, ",")
- .withOptions(queryStateOptions)
- .withDefault(initialState?.grouping ?? [])
- )
function onGroupingChange(updaterOrValue: Updater<string[]>) {
if (typeof updaterOrValue === "function") {
const newGrouping = updaterOrValue(grouping)
@@ -159,25 +369,26 @@ export function useDataTable<TData>({
}
}
- // -------- Group Expand/Collapse --------
- const [expanded, setExpanded] = React.useState<ExpandedState>({}) // or true/false
-
function onExpandedChange(updater: Updater<ExpandedState>) {
setExpanded((old) => (typeof updater === "function" ? updater(old) : updater))
}
+
+ const pagination: PaginationState = {
+ pageIndex: page - 1,
+ pageSize: perPage,
+ }
- // -------- Filters (search/faceted) URL 동기화 --------
+
+ // 기존 필터 로직들... (동일)
const filterParsers = React.useMemo(() => {
return filterFields.reduce<
Record<string, Parser<string> | Parser<string[]>>
>((acc, field) => {
if (field.options) {
- // Faceted filter -> 여러 값 가능
acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions(
queryStateOptions
)
} else {
- // Search filter -> 단일 값
acc[field.id] = parseAsString.withOptions(queryStateOptions)
}
return acc
@@ -190,25 +401,7 @@ export function useDataTable<TData>({
debounceMs
)
- // -------- PaginationState 객체 --------
- const pagination: PaginationState = {
- pageIndex: page - 1,
- pageSize: perPage,
- }
- function onPaginationChange(updaterOrValue: Updater<PaginationState>) {
- if (typeof updaterOrValue === "function") {
- const newPagination = updaterOrValue(pagination)
- void setPage(newPagination.pageIndex + 1)
- void setPerPage(newPagination.pageSize)
- } else {
- void setPage(updaterOrValue.pageIndex + 1)
- void setPerPage(updaterOrValue.pageSize)
- }
- }
-
- // -------- ColumnFiltersState --------
const initialColumnFilters: ColumnFiltersState = React.useMemo(() => {
- // AdvancedFilter 모드가 아니면 URL에서 온 filterValues를 그대로 적용
return enableAdvancedFilter
? []
: Object.entries(filterValues).reduce<ColumnFiltersState>(
@@ -228,7 +421,6 @@ export function useDataTable<TData>({
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>(initialColumnFilters)
- // 검색용 / Facet 필터용 컬럼 구분
const { searchableColumns, filterableColumns } = React.useMemo(() => {
return enableAdvancedFilter
? { searchableColumns: [], filterableColumns: [] }
@@ -258,14 +450,12 @@ export function useDataTable<TData>({
return acc
}, {})
- // 빠진 필터는 null로 설정
prev.forEach((prevFilter) => {
if (!next.some((filter) => filter.id === prevFilter.id)) {
filterUpdates[prevFilter.id] = null
}
})
- // 필터가 바뀌면 첫 페이지로
void setPage(1)
debouncedSetFilterValues(filterUpdates)
return next
@@ -283,8 +473,9 @@ export function useDataTable<TData>({
// -------- TanStack Table 인스턴스 생성 --------
const table = useReactTable({
...props,
+ data: finalData,
initialState,
- pageCount,
+ pageCount: isInfiniteMode ? -1 : pageCount,
state: {
pagination,
sorting,
@@ -295,7 +486,6 @@ export function useDataTable<TData>({
expanded,
},
- // 콜백들
onRowSelectionChange: setRowSelection,
onPaginationChange,
onSortingChange,
@@ -304,31 +494,38 @@ export function useDataTable<TData>({
onGroupingChange,
onExpandedChange,
- // 기본 모델
getCoreRowModel: getCoreRowModel(),
- // 필터 (Advanced 모드 아니면 사용)
getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
+ getPaginationRowModel: isInfiniteMode ? undefined : getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
- // 그룹 + 확장
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
- // Faceted (Facet 필터용)
getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(),
- getFacetedUniqueValues: enableAdvancedFilter
- ? undefined
- : getFacetedUniqueValues(),
+ getFacetedUniqueValues: enableAdvancedFilter ? undefined : getFacetedUniqueValues(),
- // 서버 사이드 사용
manualPagination: true,
manualSorting: true,
manualFiltering: true,
- // 그룹핑도 서버에서 처리한다면 별도 로직이 필요하지만,
- // TanStack Table v8에는 manualGrouping 옵션은 없음
- // (그룹핑을 서버에서 이미 해서 내려받는다면 row 구조 처리 필요)
-
})
- return { table }
+ return {
+ table,
+ // 무한 스크롤 정보
+ infiniteScroll: infiniteMeta,
+ // 모드 정보
+ isInfiniteMode,
+ effectivePageSize,
+ // 페이지 크기 변경 핸들러
+ handlePageSizeChange,
+ // URL 상태 관리 함수들
+ urlState: {
+ search,
+ setSearch,
+ filters: parsedFilters,
+ setFilters: (newFilters: any[]) => setFilters(JSON.stringify(newFilters)),
+ joinOperator,
+ setJoinOperator,
+ }
+ }
} \ No newline at end of file
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index 8f40162c..0fbe68a6 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -10,6 +10,7 @@ import {
formEntries,
formMetas,
forms,
+ tagClassAttributes,
tagClasses,
tags,
tagSubfieldOptions,
@@ -154,23 +155,85 @@ export async function revalidateForms(contractItemId: number) {
}
}
+export interface EditableFieldsInfo {
+ tagNo: string;
+ editableFields: string[]; // 편집 가능한 필드 키 목록
+}
+
+// TAG별 편집 가능 필드 조회 함수
+async function getEditableFieldsByTag(
+ contractItemId: number,
+ projectId: number
+): Promise<Map<string, string[]>> {
+ try {
+ // 1. 해당 contractItemId의 모든 태그 조회
+ const tagList = await db
+ .select({
+ tagNo: tags.tagNo,
+ tagClass: tags.class
+ })
+ .from(tags)
+ .where(eq(tags.contractItemId, contractItemId));
+
+ const editableFieldsMap = new Map<string, string[]>();
+
+ // 2. 각 태그별로 편집 가능 필드 계산
+ for (const tag of tagList) {
+ try {
+ // 2-1. tagClasses에서 해당 class(label)와 projectId로 tagClass 찾기
+ const tagClassResult = await db
+ .select({ id: tagClasses.id })
+ .from(tagClasses)
+ .where(
+ and(
+ eq(tagClasses.label, tag.tagClass),
+ eq(tagClasses.projectId, projectId)
+ )
+ )
+ .limit(1);
+
+ if (tagClassResult.length === 0) {
+ console.warn(`No tagClass found for class: ${tag.tagClass}, projectId: ${projectId}`);
+ editableFieldsMap.set(tag.tagNo, []); // 편집 불가능
+ continue;
+ }
+
+ // 2-2. tagClassAttributes에서 편집 가능한 필드 목록 조회
+ const editableAttributes = await db
+ .select({ attId: tagClassAttributes.attId })
+ .from(tagClassAttributes)
+ .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id))
+ .orderBy(tagClassAttributes.seq);
+
+ // 2-3. attId 목록 저장
+ const editableFields = editableAttributes.map(attr => attr.attId);
+ editableFieldsMap.set(tag.tagNo, editableFields);
+
+ } catch (error) {
+ console.error(`Error processing tag ${tag.tagNo}:`, error);
+ editableFieldsMap.set(tag.tagNo, []); // 에러 시 편집 불가능
+ }
+ }
+
+ return editableFieldsMap;
+ } catch (error) {
+ console.error('Error getting editable fields by tag:', error);
+ return new Map();
+ }
+}
/**
* "가장 최신 1개 row"를 가져오고,
* data가 배열이면 그 배열을 반환,
* 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱.
*/
export async function getFormData(formCode: string, contractItemId: number) {
- // 고유 캐시 키 (formCode + contractItemId)
const cacheKey = `form-data-${formCode}-${contractItemId}`;
console.log(cacheKey, "getFormData")
try {
- // 1) unstable_cache로 전체 로직을 감싼다
const result = await unstable_cache(
async () => {
- // --- 기존 로직 시작 (projectId 고려하도록 수정) ---
-
- // (0) contractItemId로부터 projectId 조회
+ // 기존 로직으로 projectId, columns, data 가져오기
const contractItemResult = await db
.select({
projectId: projects.id
@@ -183,12 +246,11 @@ export async function getFormData(formCode: string, contractItemId: number) {
if (contractItemResult.length === 0) {
console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`);
- return { columns: null, data: [] };
+ return { columns: null, data: [], editableFieldsMap: new Map() };
}
const projectId = contractItemResult[0].projectId;
- // (1) form_metas 조회 - 이제 projectId도 조건에 포함
const metaRows = await db
.select()
.from(formMetas)
@@ -204,10 +266,9 @@ export async function getFormData(formCode: string, contractItemId: number) {
const meta = metaRows[0] ?? null;
if (!meta) {
console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
- return { columns: null, data: [] };
+ return { columns: null, data: [], editableFieldsMap: new Map() };
}
- // (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행
const entryRows = await db
.select()
.from(formEntries)
@@ -222,19 +283,11 @@ export async function getFormData(formCode: string, contractItemId: number) {
const entry = entryRows[0] ?? null;
- // columns: DB에 저장된 JSON (DataTableColumnJSON[])
let columns = meta.columns as DataTableColumnJSON[];
-
- // 제외할 key들 정의
const excludeKeys = ['CLS_ID', 'BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
-
- // 제외할 key들을 가진 컬럼들을 필터링해서 제거
columns = columns.filter(col => !excludeKeys.includes(col.key));
columns.forEach((col) => {
- // 이미 displayLabel이 있으면 그대로 두고,
- // 없으면 uom이 있으면 "label (uom)" 형태,
- // 둘 다 없으면 label만 쓴다.
if (!col.displayLabel) {
if (col.uom) {
col.displayLabel = `${col.label} (${col.uom})`;
@@ -244,39 +297,35 @@ export async function getFormData(formCode: string, contractItemId: number) {
}
});
- // data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열
let data: Array<Record<string, any>> = [];
if (entry) {
if (Array.isArray(entry.data)) {
data = entry.data;
} else {
- console.warn(
- "formEntries data was not an array. Using empty array."
- );
+ console.warn("formEntries data was not an array. Using empty array.");
}
}
- return { columns, data, projectId }; // projectId도 반환 (필요시)
- // --- 기존 로직 끝 ---
+ // *** 새로 추가: 편집 가능 필드 정보 계산 ***
+ const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId);
+
+ return { columns, data, editableFieldsMap };
},
- [cacheKey], // 캐시 키 의존성
+ [cacheKey],
{
- revalidate: 60, // 1분 캐시
- tags: [cacheKey], // 캐시 태그
+ revalidate: 60,
+ tags: [cacheKey],
}
)();
return result;
} catch (cacheError) {
console.error(`[getFormData] Cache operation failed:`, cacheError);
-
- // --- fallback: 캐시 문제 시 직접 쿼리 시도 ---
+
+ // Fallback logic (기존과 동일하게 editableFieldsMap 추가)
try {
- console.log(
- `[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`
- );
+ console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`);
- // (0) contractItemId로부터 projectId 조회
const contractItemResult = await db
.select({
projectId: projects.id
@@ -289,12 +338,11 @@ export async function getFormData(formCode: string, contractItemId: number) {
if (contractItemResult.length === 0) {
console.warn(`[getFormData] Fallback: No contract item found with ID: ${contractItemId}`);
- return { columns: null, data: [] };
+ return { columns: null, data: [], editableFieldsMap: new Map() };
}
const projectId = contractItemResult[0].projectId;
- // (1) form_metas - projectId 고려
const metaRows = await db
.select()
.from(formMetas)
@@ -310,10 +358,9 @@ export async function getFormData(formCode: string, contractItemId: number) {
const meta = metaRows[0] ?? null;
if (!meta) {
console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
- return { columns: null, data: [] };
+ return { columns: null, data: [], editableFieldsMap: new Map() };
}
- // (2) form_entries
const entryRows = await db
.select()
.from(formEntries)
@@ -329,17 +376,10 @@ export async function getFormData(formCode: string, contractItemId: number) {
const entry = entryRows[0] ?? null;
let columns = meta.columns as DataTableColumnJSON[];
-
- // 제외할 key들 정의
const excludeKeys = ['CLS_ID', 'BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
-
- // 제외할 key들을 가진 컬럼들을 필터링해서 제거
columns = columns.filter(col => !excludeKeys.includes(col.key));
columns.forEach((col) => {
- // 이미 displayLabel이 있으면 그대로 두고,
- // 없으면 uom이 있으면 "label (uom)" 형태,
- // 둘 다 없으면 label만 쓴다.
if (!col.displayLabel) {
if (col.uom) {
col.displayLabel = `${col.label} (${col.uom})`;
@@ -354,21 +394,21 @@ export async function getFormData(formCode: string, contractItemId: number) {
if (Array.isArray(entry.data)) {
data = entry.data;
} else {
- console.warn(
- "formEntries data was not an array. Using empty array (fallback)."
- );
+ console.warn("formEntries data was not an array. Using empty array (fallback).");
}
}
- return { columns, data, projectId }; // projectId도 반환 (필요시)
+ // Fallback에서도 편집 가능 필드 정보 계산
+ const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId);
+
+ return { columns, data, projectId, editableFieldsMap };
} catch (dbError) {
console.error(`[getFormData] Fallback DB query failed:`, dbError);
- return { columns: null, data: [] };
+ return { columns: null, data: [], editableFieldsMap: new Map() };
}
}
}
-
-/**
+/**1
* contractId와 formCode(itemCode)를 사용하여 contractItemId를 찾는 서버 액션
*
* @param contractId - 계약 ID
diff --git a/lib/items/service.ts b/lib/items/service.ts
index 99ef79ef..35d2fa01 100644
--- a/lib/items/service.ts
+++ b/lib/items/service.ts
@@ -25,29 +25,105 @@ import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem,
* Next.js의 unstable_cache를 사용해 일정 시간 캐시.
*/
export async function getItems(input: GetItemsSchema) {
-
+ const safePerPage = Math.min(input.perPage, 100);
+
return unstable_cache(
async () => {
try {
- const offset = (input.page - 1) * input.perPage;
+ const offset = (input.page - 1) * safePerPage;
+
+ const advancedWhere = filterColumns({
+ table: items,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(items.itemLevel, s),
+ ilike(items.itemCode, s),
+ ilike(items.itemName, s),
+ ilike(items.description, s),
+ ilike(items.parentItemCode, s),
+ ilike(items.unitOfMeasure, s),
+ ilike(items.steelType, s),
+ ilike(items.gradeMaterial, s),
+ ilike(items.baseUnitOfMeasure, s),
+ ilike(items.changeDate, s)
+ );
+ }
+
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(items[item.id]) : asc(items[item.id])
+ )
+ : [asc(items.createdAt)];
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectItems(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: safePerPage,
+ });
+
+ const total = await countItems(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / safePerPage);
+ return { data, pageCount };
+ } catch (err) {
+ console.error(err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify({...input, perPage: safePerPage})],
+ {
+ revalidate: 3600,
+ tags: ["items"],
+ }
+ )();
+}
+
+export interface GetItemsInfiniteInput extends Omit<GetItemsSchema, 'page' | 'perPage'> {
+ cursor?: string;
+ limit?: number;
+}
+
+// 무한 스크롤 결과 타입
+export interface GetItemsInfiniteResult {
+ data: any[];
+ hasNextPage: boolean;
+ nextCursor: string | null;
+ total?: number | null;
+}
- // const advancedTable = input.flags.includes("advancedTable");
- const advancedTable = true;
+export async function getItemsInfinite(input: GetItemsInfiniteInput): Promise<GetItemsInfiniteResult> {
+ return unstable_cache(
+ async () => {
+ try {
+ // 페이지 크기 제한 (기존과 동일한 방식)
+ const safeLimit = Math.min(input.limit || 50, 100);
- // advancedTable 모드면 filterColumns()로 where 절 구성
+ // 고급 필터링 (기존과 완전 동일)
const advancedWhere = filterColumns({
table: items,
filters: input.filters,
joinOperator: input.joinOperator,
});
-
- let globalWhere
+ // 전역 검색 (기존과 완전 동일)
+ let globalWhere;
if (input.search) {
- const s = `%${input.search}%`
+ const s = `%${input.search}%`;
globalWhere = or(
ilike(items.itemLevel, s),
- ilike(items.itemCode, s),
+ ilike(items.itemCode, s),
ilike(items.itemName, s),
ilike(items.description, s),
ilike(items.parentItemCode, s),
@@ -56,58 +132,107 @@ export async function getItems(input: GetItemsSchema) {
ilike(items.gradeMaterial, s),
ilike(items.baseUnitOfMeasure, s),
ilike(items.changeDate, s)
- )
- // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ );
}
- const finalWhere = and(
- // advancedWhere or your existing conditions
- advancedWhere,
- globalWhere // and()함수로 결합 or or() 등으로 결합
- )
-
+ // 커서 기반 페이지네이션 조건 추가
+ let cursorWhere;
+ if (input.cursor) {
+ cursorWhere = gt(items.id, input.cursor);
+ }
- // 아니면 ilike, inArray, gte 등으로 where 절 구성
- const where = finalWhere
-
+ // 모든 조건 결합
+ const finalWhere = and(advancedWhere, globalWhere, cursorWhere);
+
+ // 정렬 (기존과 동일하지만 id 정렬 보장)
+ let orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(items[item.id]) : asc(items[item.id])
+ )
+ : [asc(items.createdAt)];
+
+ // 무한 스크롤에서는 id 정렬이 필수 (커서 기반 페이지네이션용)
+ const hasIdSort = orderBy.some(sort => {
+ const column = sort.constructor.name.includes('desc')
+ ? sort.column
+ : sort.column;
+ return column === items.id;
+ });
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(items[item.id]) : asc(items[item.id])
- )
- : [asc(items.createdAt)];
+ if (!hasIdSort) {
+ orderBy.push(asc(items.id));
+ }
- // 트랜잭션 내부에서 Repository 호출
+ // 트랜잭션으로 데이터 조회 (기존과 동일한 패턴)
const { data, total } = await db.transaction(async (tx) => {
+ // limit + 1로 다음 페이지 존재 여부 확인
const data = await selectItems(tx, {
- where,
+ where: finalWhere,
orderBy,
- offset,
- limit: input.perPage,
+ limit: safeLimit + 1,
});
- const total = await countItems(tx, where);
+ // 첫 페이지에서만 전체 개수 계산 (성능 최적화)
+ let total = null;
+ if (!input.cursor) {
+ // 커서 조건 제외하고 전체 개수 계산
+ const countWhere = and(advancedWhere, globalWhere);
+ total = await countItems(tx, countWhere);
+ }
+
return { data, total };
});
- const pageCount = Math.ceil(total / input.perPage);
+ // 다음 페이지 존재 여부 및 커서 설정
+ const hasNextPage = data.length > safeLimit;
+ const resultItems = hasNextPage ? data.slice(0, safeLimit) : data;
+ const nextCursor = hasNextPage && resultItems.length > 0
+ ? resultItems[resultItems.length - 1].id
+ : null;
+
+ return {
+ data: resultItems,
+ hasNextPage,
+ nextCursor,
+ total,
+ };
- return { data, pageCount };
} catch (err) {
- // 에러 발생 시 디폴트
- console.error(err)
- return { data: [], pageCount: 0 };
+ console.error('getItemsInfinite error:', err);
+ return {
+ data: [],
+ hasNextPage: false,
+ nextCursor: null,
+ total: 0,
+ };
}
},
- [JSON.stringify(input)], // 캐싱 키
+ [JSON.stringify({ ...input, limit: Math.min(input.limit || 50, 100) })],
{
revalidate: 3600,
- tags: ["items"], // revalidateTag("items") 호출 시 무효화
+ tags: ["items"],
}
)();
}
+// 통합된 Items 조회 함수 (모드별 자동 분기)
+export async function getItemsUnified(input: GetItemsSchema & { mode?: 'pagination' | 'infinite'; cursor?: string }): Promise<any> {
+ // perPage 기반 모드 자동 결정
+ const isInfiniteMode = input.perPage >= 1_000_000;
+
+ if (isInfiniteMode || input.mode === 'infinite') {
+ // 무한 스크롤 모드
+ return getItemsInfinite({
+ ...input,
+ limit: 50, // 실제로는 50개씩 로드
+ cursor: input.cursor,
+ });
+ } else {
+ // 기존 페이지네이션 모드
+ return getItems(input);
+ }
+}
+
/* -----------------------------------------------------
2) 생성(Create)
diff --git a/lib/items/table/items-table.tsx b/lib/items/table/items-table.tsx
index 2bc1c913..c05b4348 100644
--- a/lib/items/table/items-table.tsx
+++ b/lib/items/table/items-table.tsx
@@ -9,8 +9,12 @@ import type {
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
+import { InfiniteDataTable } from "@/components/data-table/infinite-data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { useFeatureFlags } from "./feature-flags-provider"
+import { Button } from "@/components/ui/button"
+import { RotateCcw, Info } from "lucide-react"
+import { Alert, AlertDescription } from "@/components/ui/alert"
import { getItems } from "../service"
import { Item } from "@/db/schema/items"
@@ -20,7 +24,7 @@ import { UpdateItemSheet } from "./update-item-sheet"
import { DeleteItemsDialog } from "./delete-items-dialog"
interface ItemsTableProps {
- promises: Promise<
+ promises?: Promise<
[
Awaited<ReturnType<typeof getItems>>,
]
@@ -30,11 +34,11 @@ interface ItemsTableProps {
export function ItemsTable({ promises }: ItemsTableProps) {
const { featureFlags } = useFeatureFlags()
- const [{ data, pageCount }] =
- React.use(promises)
-
- console.log(data)
+ // 페이지네이션 모드 데이터
+ const paginationData = promises ? React.use(promises) : null
+ const [{ data = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }]
+ console.log('ItemsTable data:', data.length, 'items')
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<Item> | null>(null)
@@ -44,17 +48,7 @@ export function ItemsTable({ promises }: ItemsTableProps) {
[setRowAction]
)
- /**
- * This component can render either a faceted filter or a search filter based on the `options` prop.
- *
- * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
- *
- * Each `option` object has the following properties:
- * @prop {string} label - The label for the filter option.
- * @prop {string} value - The value for the filter option.
- * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
- * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
- */
+ // 기존 필터 필드들
const filterFields: DataTableFilterField<Item>[] = [
{
id: "itemCode",
@@ -62,16 +56,6 @@ export function ItemsTable({ promises }: ItemsTableProps) {
},
]
- /**
- * Advanced filter fields for the data table.
- * These fields provide more complex filtering options compared to the regular filterFields.
- *
- * Key differences from regular filterFields:
- * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
- * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
- * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
- * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
- */
const advancedFilterFields: DataTableAdvancedFilterField<Item>[] = [
{
id: "itemLevel",
@@ -130,8 +114,15 @@ export function ItemsTable({ promises }: ItemsTableProps) {
},
]
-
- const { table } = useDataTable({
+ // 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환)
+ const {
+ table,
+ infiniteScroll,
+ isInfiniteMode,
+ effectivePageSize,
+ handlePageSizeChange,
+ urlState
+ } = useDataTable({
data,
columns,
pageCount,
@@ -145,24 +136,104 @@ export function ItemsTable({ promises }: ItemsTableProps) {
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
clearOnDefault: true,
+ // 무한 스크롤 설정
+ infiniteScrollConfig: {
+ apiEndpoint: "/api/table/items/infinite",
+ tableName: "items",
+ maxPageSize: 100,
+ },
})
- return (
- <>
- <DataTable
- table={table}
-
- >
-
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <ItemsTableToolbarActions table={table} />
- </DataTableAdvancedToolbar>
+ // 새로고침 핸들러
+ const handleRefresh = () => {
+ if (isInfiniteMode && infiniteScroll) {
+ infiniteScroll.refresh()
+ } else {
+ window.location.reload()
+ }
+ }
- </DataTable>
+ return (
+ <div className="w-full space-y-2.5">
+
+ {/* <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isInfiniteMode && infiniteScroll?.isLoading}
+ >
+ <RotateCcw className="h-4 w-4 mr-2" />
+ 새로고침
+ </Button>
+ </div>
+ </div> */}
+
+ {/* 에러 상태 (무한 스크롤 모드) */}
+ {isInfiniteMode && infiniteScroll?.error && (
+ <Alert variant="destructive">
+ <AlertDescription>
+ 데이터를 불러오는 중 오류가 발생했습니다.
+ <Button
+ variant="link"
+ size="sm"
+ onClick={() => infiniteScroll.reset()}
+ className="ml-2 p-0 h-auto"
+ >
+ 다시 시도
+ </Button>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 로딩 상태가 아닐 때만 테이블 렌더링 */}
+ {!(isInfiniteMode && infiniteScroll?.isLoading && infiniteScroll?.isEmpty) ? (
+ <>
+ {/* 도구 모음 */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ItemsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ {/* 테이블 렌더링 */}
+ {isInfiniteMode ? (
+ // 무한 스크롤 모드: InfiniteDataTable 사용 (자체 페이지네이션 없음)
+ <InfiniteDataTable
+ table={table}
+ hasNextPage={infiniteScroll?.hasNextPage || false}
+ isLoadingMore={infiniteScroll?.isLoadingMore || false}
+ onLoadMore={infiniteScroll?.loadMore}
+ totalCount={infiniteScroll?.totalCount}
+ isEmpty={infiniteScroll?.isEmpty || false}
+ compact={false}
+ autoSizeColumns={true}
+ />
+ ) : (
+ // 페이지네이션 모드: DataTable 사용 (내장 페이지네이션 활용)
+ <DataTable
+ table={table}
+ compact={false}
+ autoSizeColumns={true}
+ />
+ )}
+ </>
+ ) : (
+ /* 로딩 스켈레톤 (무한 스크롤 초기 로딩) */
+ <div className="space-y-3">
+ <div className="text-sm text-muted-foreground mb-4">
+ 무한 스크롤 모드로 데이터를 로드하고 있습니다...
+ </div>
+ {Array.from({ length: 10 }).map((_, i) => (
+ <div key={i} className="h-12 w-full bg-muted animate-pulse rounded" />
+ ))}
+ </div>
+ )}
+
+ {/* 기존 다이얼로그들 */}
<UpdateItemSheet
open={rowAction?.type === "update"}
onOpenChange={() => setRowAction(null)}
@@ -175,6 +246,6 @@ export function ItemsTable({ promises }: ItemsTableProps) {
showTrigger={false}
onSuccess={() => rowAction?.row.toggleSelected(false)}
/>
- </>
+ </div>
)
-}
+} \ No newline at end of file
diff --git a/lib/items/validations.ts b/lib/items/validations.ts
index 14fc27b1..bb90e931 100644
--- a/lib/items/validations.ts
+++ b/lib/items/validations.ts
@@ -37,6 +37,7 @@ export const searchParamsCache = createSearchParamsCache({
search: parseAsString.withDefault(""),
})
+export type GetItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
export const createItemSchema = z.object({
itemCode: z.string().min(1, "아이템 코드는 필수입니다"),
@@ -51,6 +52,8 @@ export const createItemSchema = z.object({
changeDate: z.string().max(8).nullable().optional(),
baseUnitOfMeasure: z.string().max(3).nullable().optional(),
})
+export type CreateItemSchema = z.infer<typeof createItemSchema>
+
export const updateItemSchema = z.object({
itemCode: z.string().optional(),
@@ -65,7 +68,4 @@ export const updateItemSchema = z.object({
changeDate: z.string().max(8).nullable().optional(),
baseUnitOfMeasure: z.string().max(3).nullable().optional(),
})
-
-export type GetItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
-export type CreateItemSchema = z.infer<typeof createItemSchema>
export type UpdateItemSchema = z.infer<typeof updateItemSchema>
diff --git a/lib/rfqs-tech/table/rfqs-table-columns.tsx b/lib/rfqs-tech/table/rfqs-table-columns.tsx
index 86660dc7..03089341 100644
--- a/lib/rfqs-tech/table/rfqs-table-columns.tsx
+++ b/lib/rfqs-tech/table/rfqs-table-columns.tsx
@@ -95,7 +95,7 @@ export function getColumns({
// 아이템과 첨부파일이 모두 0보다 커야 진행 가능
if (itemCount > 0 && attachCount > 0) {
router.push(
- `/evcp/rfq/${rfq.rfqId}`
+ `/evcp/rfq-tech/${rfq.rfqId}`
)
} else {
// 조건을 충족하지 않는 경우 토스트 알림 표시
diff --git a/lib/sedp/sync-object-class.ts b/lib/sedp/sync-object-class.ts
index 68b9384f..5fd3ebff 100644
--- a/lib/sedp/sync-object-class.ts
+++ b/lib/sedp/sync-object-class.ts
@@ -1,5 +1,5 @@
import db from "@/db/db";
-import { projects, tagClasses, tagTypes } from '@/db/schema';
+import { projects, tagClassAttributes, tagClasses, tagTypes } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { getSEDPToken } from "./sedp-token";
@@ -13,7 +13,7 @@ interface ObjectClass {
DESC: string;
TAG_TYPE_ID: string | null;
PRT_CLS_ID: string | null;
- LNK_ATT: any[];
+ LNK_ATT: LinkAttribute[];
DELETED: boolean;
DEL_USER: string | null;
DEL_DTM: string | null;
@@ -40,12 +40,123 @@ interface SyncResult {
count?: number;
error?: string;
}
+
interface TagType {
TYPE_ID: string;
DESC: string;
PROJ_NO: string;
// 기타 필드들...
}
+
+interface LinkAttribute {
+ ATT_ID: string;
+ DEF_VAL: string;
+ UOM_ID: string;
+ SEQ: number;
+}
+
+// 태그 클래스 속성 저장 함수
+async function saveTagClassAttributes(
+ tagClassId: number,
+ attributes: LinkAttribute[]
+): Promise<void> {
+ try {
+ if (attributes.length === 0) {
+ console.log(`태그 클래스 ID ${tagClassId}에 저장할 속성이 없습니다.`);
+ return;
+ }
+
+ // 현재 태그 클래스의 모든 속성 조회
+ const existingAttributes = await db.select()
+ .from(tagClassAttributes)
+ .where(eq(tagClassAttributes.tagClassId, tagClassId));
+
+ // 속성 ID 기준으로 맵 생성
+ const existingAttributeMap = new Map(
+ existingAttributes.map(attr => [attr.attId, attr])
+ );
+
+ // API에 있는 속성 ID 목록
+ const apiAttributeIds = new Set(attributes.map(attr => attr.ATT_ID));
+
+ // 삭제할 속성 ID 목록
+ const attributeIdsToDelete = existingAttributes
+ .map(attr => attr.attId)
+ .filter(attId => !apiAttributeIds.has(attId));
+
+ // 새로 추가할 항목과 업데이트할 항목 분리
+ const toInsert = [];
+ const toUpdate = [];
+
+ for (const attr of attributes) {
+ const record = {
+ tagClassId: tagClassId,
+ attId: attr.ATT_ID,
+ defVal: attr.DEF_VAL,
+ uomId: attr.UOM_ID,
+ seq: attr.SEQ,
+ updatedAt: new Date()
+ };
+
+ if (existingAttributeMap.has(attr.ATT_ID)) {
+ // 업데이트 항목
+ toUpdate.push(record);
+ } else {
+ // 새로 추가할 항목
+ toInsert.push({
+ ...record,
+ createdAt: new Date()
+ });
+ }
+ }
+
+ // 1. 새 항목 삽입
+ if (toInsert.length > 0) {
+ await db.insert(tagClassAttributes).values(toInsert);
+ console.log(`태그 클래스 ID ${tagClassId}에 ${toInsert.length}개의 새 속성 추가 완료`);
+ }
+
+ // 2. 기존 항목 업데이트
+ for (const item of toUpdate) {
+ await db.update(tagClassAttributes)
+ .set({
+ defVal: item.defVal,
+ uomId: item.uomId,
+ seq: item.seq,
+ updatedAt: item.updatedAt
+ })
+ .where(
+ and(
+ eq(tagClassAttributes.tagClassId, item.tagClassId),
+ eq(tagClassAttributes.attId, item.attId)
+ )
+ );
+ }
+
+ if (toUpdate.length > 0) {
+ console.log(`태그 클래스 ID ${tagClassId}의 ${toUpdate.length}개 속성 업데이트 완료`);
+ }
+
+ // 3. 더 이상 존재하지 않는 항목 삭제
+ if (attributeIdsToDelete.length > 0) {
+ for (const attId of attributeIdsToDelete) {
+ await db.delete(tagClassAttributes)
+ .where(
+ and(
+ eq(tagClassAttributes.tagClassId, tagClassId),
+ eq(tagClassAttributes.attId, attId)
+ )
+ );
+ }
+ console.log(`태그 클래스 ID ${tagClassId}에서 ${attributeIdsToDelete.length}개의 속성 삭제 완료`);
+ }
+
+ } catch (error) {
+ console.error(`태그 클래스 속성 저장 실패 (태그 클래스 ID: ${tagClassId}):`, error);
+ throw error;
+ }
+}
+
// 오브젝트 클래스 데이터 가져오기
async function getObjectClasses(projectCode: string, token:string): Promise<ObjectClass[]> {
try {
@@ -255,7 +366,7 @@ async function getAllTagTypes(projectCode: string, token: string): Promise<TagTy
}
}
-// 4. 기존 함수 수정 - saveObjectClassesToDatabase
+// LNK_ATT 속성 처리가 포함된 오브젝트 클래스 저장 함수
async function saveObjectClassesToDatabase(
projectId: number,
classes: ObjectClass[],
@@ -307,7 +418,6 @@ async function saveObjectClassesToDatabase(
return 0;
}
- // 이하 기존 코드와 동일
// 현재 프로젝트의 오브젝트 클래스 코드 가져오기
const existingClasses = await db.select()
.from(tagClasses)
@@ -359,14 +469,31 @@ async function saveObjectClassesToDatabase(
// 트랜잭션 실행
let totalChanged = 0;
- // 1. 새 항목 삽입
+ // 1. 새 항목 삽입 및 속성 처리
if (toInsert.length > 0) {
- await db.insert(tagClasses).values(toInsert);
+ // returning을 사용하여 삽입된 레코드의 ID와 code를 가져옴
+ const insertedClasses = await db.insert(tagClasses)
+ .values(toInsert)
+ .returning({ id: tagClasses.id, code: tagClasses.code });
+
totalChanged += toInsert.length;
console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 오브젝트 클래스 추가 완료`);
+
+ // 새로 삽입된 각 클래스의 LNK_ATT 속성 처리
+ for (const insertedClass of insertedClasses) {
+ const originalClass = classesToSave.find(cls => cls.CLS_ID === insertedClass.code);
+ if (originalClass && originalClass.LNK_ATT && originalClass.LNK_ATT.length > 0) {
+ try {
+ await saveTagClassAttributes(insertedClass.id, originalClass.LNK_ATT);
+ } catch (error) {
+ console.error(`태그 클래스 ${insertedClass.code}의 속성 저장 실패:`, error);
+ // 속성 저장 실패해도 계속 진행
+ }
+ }
+ }
}
- // 2. 기존 항목 업데이트
+ // 2. 기존 항목 업데이트 및 속성 처리
for (const item of toUpdate) {
await db.update(tagClasses)
.set({
@@ -380,6 +507,30 @@ async function saveObjectClassesToDatabase(
eq(tagClasses.projectId, item.projectId)
)
);
+
+ // 업데이트된 클래스의 ID 조회
+ const updatedClass = await db.select({ id: tagClasses.id })
+ .from(tagClasses)
+ .where(
+ and(
+ eq(tagClasses.code, item.code),
+ eq(tagClasses.projectId, item.projectId)
+ )
+ )
+ .limit(1);
+
+ if (updatedClass.length > 0) {
+ const originalClass = classesToSave.find(cls => cls.CLS_ID === item.code);
+ if (originalClass && originalClass.LNK_ATT) {
+ try {
+ await saveTagClassAttributes(updatedClass[0].id, originalClass.LNK_ATT);
+ } catch (error) {
+ console.error(`태그 클래스 ${item.code}의 속성 업데이트 실패:`, error);
+ // 속성 업데이트 실패해도 계속 진행
+ }
+ }
+ }
+
totalChanged += 1;
}
@@ -387,7 +538,7 @@ async function saveObjectClassesToDatabase(
console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 오브젝트 클래스 업데이트 완료`);
}
- // 3. 더 이상 존재하지 않는 항목 삭제
+ // 3. 더 이상 존재하지 않는 항목 삭제 (CASCADE로 속성도 자동 삭제됨)
if (codesToDelete.length > 0) {
for (const code of codesToDelete) {
await db.delete(tagClasses)
@@ -409,7 +560,7 @@ async function saveObjectClassesToDatabase(
}
}
-// 5. 메인 동기화 함수 수정
+// 메인 동기화 함수
export async function syncObjectClasses() {
try {
console.log('오브젝트 클래스 동기화 시작:', new Date().toISOString());
@@ -420,7 +571,7 @@ export async function syncObjectClasses() {
// 2. 모든 프로젝트 가져오기
const allProjects = await db.select().from(projects);
- // 3. 모든 프로젝트에 대해 먼저 태그 타입 동기화 (바로 이 부분이 추가됨)
+ // 3. 모든 프로젝트에 대해 먼저 태그 타입 동기화
console.log('모든 프로젝트의 태그 타입 동기화 시작...');
const tagTypeResults = await Promise.allSettled(
@@ -467,6 +618,7 @@ export async function syncObjectClasses() {
const objectClasses = await getObjectClasses(project.code, token);
// 데이터베이스에 저장 (skipTagTypeSync를 true로 설정하여 태그 타입 동기화 건너뜀)
+ // 이 과정에서 LNK_ATT 속성도 함께 처리됨
const count = await saveObjectClassesToDatabase(project.id, objectClasses, project.code, token, true);
return {
@@ -534,4 +686,80 @@ export async function syncObjectClasses() {
console.error('오브젝트 클래스 동기화 중 오류 발생:', error);
throw error;
}
+}
+
+// 유틸리티 함수들 (필요시 사용)
+export async function getTagClassWithAttributes(tagClassId: number) {
+ const tagClass = await db.select()
+ .from(tagClasses)
+ .where(eq(tagClasses.id, tagClassId))
+ .limit(1);
+
+ if (tagClass.length === 0) {
+ return null;
+ }
+
+ const attributes = await db.select()
+ .from(tagClassAttributes)
+ .where(eq(tagClassAttributes.tagClassId, tagClassId))
+ .orderBy(tagClassAttributes.seq);
+
+ return {
+ ...tagClass[0],
+ attributes
+ };
+}
+
+export async function getProjectTagClassesWithAttributes(projectId: number) {
+ const result = await db.select({
+ // 태그 클래스 정보
+ id: tagClasses.id,
+ code: tagClasses.code,
+ label: tagClasses.label,
+ tagTypeCode: tagClasses.tagTypeCode,
+ createdAt: tagClasses.createdAt,
+ updatedAt: tagClasses.updatedAt,
+ // 속성 정보
+ attributeId: tagClassAttributes.id,
+ attId: tagClassAttributes.attId,
+ defVal: tagClassAttributes.defVal,
+ uomId: tagClassAttributes.uomId,
+ seq: tagClassAttributes.seq
+ })
+ .from(tagClasses)
+ .leftJoin(tagClassAttributes, eq(tagClasses.id, tagClassAttributes.tagClassId))
+ .where(eq(tagClasses.projectId, projectId))
+ .orderBy(tagClasses.code, tagClassAttributes.seq);
+
+ // 결과를 태그 클래스별로 그룹화
+ const groupedResult = result.reduce((acc, row) => {
+ const tagClassId = row.id;
+
+ if (!acc[tagClassId]) {
+ acc[tagClassId] = {
+ id: row.id,
+ code: row.code,
+ label: row.label,
+ tagTypeCode: row.tagTypeCode,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ attributes: []
+ };
+ }
+
+ // 속성이 있는 경우에만 추가
+ if (row.attributeId) {
+ acc[tagClassId].attributes.push({
+ id: row.attributeId,
+ attId: row.attId,
+ defVal: row.defVal,
+ uomId: row.uomId,
+ seq: row.seq
+ });
+ }
+
+ return acc;
+ }, {} as Record<number, any>);
+
+ return Object.values(groupedResult);
} \ No newline at end of file
diff --git a/lib/tbe-tech/table/tbe-table-columns.tsx b/lib/tbe-tech/table/tbe-table-columns.tsx
index 2349db7e..bb86e578 100644
--- a/lib/tbe-tech/table/tbe-table-columns.tsx
+++ b/lib/tbe-tech/table/tbe-table-columns.tsx
@@ -293,7 +293,7 @@ const commentsColumn: ColumnDef<VendorWithTbeFields> = {
),
cell: ({ row }) => {
const vendor = row.original
- const commCount = vendor.comments?.length ?? 0
+ const commCount = vendor.comments?.filter(c => c.evaluationId === vendor.tbeId)?.length ?? 0
function handleClick() {
// setRowAction() 로 type 설정
diff --git a/lib/tbe-tech/table/tbe-table.tsx b/lib/tbe-tech/table/tbe-table.tsx
index 3d981450..3537f16a 100644
--- a/lib/tbe-tech/table/tbe-table.tsx
+++ b/lib/tbe-tech/table/tbe-table.tsx
@@ -61,7 +61,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
// 새로운 벤더 데이터 추가
vendorMap.set(vendorId, {
...item,
- vendorResponseId: item.id,
+ // vendorResponseId: item.id,
technicalResponseId: item.id,
rfqId: item.rfqId
})
@@ -106,6 +106,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
openCommentSheet(
rowAction.row.original.vendorId ?? 0,
rowAction.row.original.rfqId ?? 0,
+ rowAction.row.original.tbeId ?? 0,
)
} else if (rowAction.type === "files") {
openFilesDialog(
@@ -144,10 +145,10 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
// -----------------------------------------------------------
// 댓글 시트 열기
// -----------------------------------------------------------
- async function openCommentSheet(vendorId: number, rfqId: number) {
+ async function openCommentSheet(vendorId: number, rfqId: number, tbeId: number) {
setInitialComments([])
setIsLoadingComments(true)
- const comments = rowAction?.row.original.comments
+ const comments = rowAction?.row.original.comments?.filter(c => c.evaluationId === tbeId)
try {
if (comments && comments.length > 0) {
const commentWithAttachments: TbeComment[] = await Promise.all(
diff --git a/lib/tech-vendor-rfq-response/service.ts b/lib/tech-vendor-rfq-response/service.ts
new file mode 100644
index 00000000..b706d42a
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/service.ts
@@ -0,0 +1,467 @@
+'use server'
+
+import { revalidateTag, unstable_cache } from "next/cache";
+import db from "@/db/db";
+import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
+import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq";
+import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq";
+import { items, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+import { GetRfqsForVendorsSchema } from "../rfqs-tech/validations";
+import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table";
+import * as z from "zod"
+
+
+
+export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ const offset = (input.page - 1) * input.perPage;
+ const limit = input.perPage;
+
+ // 1) 메인 쿼리: vendorResponsesView 사용
+ const { rows, total } = await db.transaction(async (tx) => {
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorResponsesView.rfqCode} ILIKE ${s}`,
+ sql`${vendorResponsesView.projectName} ILIKE ${s}`,
+ sql`${vendorResponsesView.rfqDescription} ILIKE ${s}`
+ );
+ }
+
+ // 협력업체 ID 필터링
+ const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
+
+ // 정렬: 응답 시간순
+ const orderBy = [desc(vendorResponsesView.respondedAt)];
+
+ // (A) 데이터 조회
+ const data = await tx
+ .select()
+ .from(vendorResponsesView)
+ .where(mainWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ // (B) 전체 개수 카운트
+ const [{ count }] = await tx
+ .select({
+ count: sql<number>`count(*)`.as("count"),
+ })
+ .from(vendorResponsesView)
+ .where(mainWhere);
+
+ return { rows: data, total: Number(count) };
+ });
+
+ // 2) rfqId 고유 목록 추출
+ const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))];
+ if (distinctRfqs.length === 0) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // 3) 추가 데이터 조회
+ // 3-A) RFQ 아이템
+ const itemsAll = await db
+ .select({
+ id: rfqItems.id,
+ rfqId: rfqItems.rfqId,
+ itemCode: rfqItems.itemCode,
+ itemList: sql<string>`COALESCE(${itemOffshoreTop.itemList}, ${itemOffshoreHull.itemList})`.as('itemList'),
+ subItemList: sql<string>`COALESCE(${itemOffshoreTop.subItemList}, ${itemOffshoreHull.subItemList})`.as('subItemList'),
+ quantity: rfqItems.quantity,
+ description: rfqItems.description,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .leftJoin(itemOffshoreTop, eq(rfqItems.itemCode, itemOffshoreTop.itemCode))
+ .leftJoin(itemOffshoreHull, eq(rfqItems.itemCode, itemOffshoreHull.itemCode))
+ .where(inArray(rfqItems.rfqId, distinctRfqs));
+
+ // 3-B) RFQ 첨부 파일 (협력업체용)
+ const attachAll = await db
+ .select()
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.rfqId, distinctRfqs),
+ isNull(rfqAttachments.vendorId)
+ )
+ );
+
+ // 3-C) RFQ 코멘트
+ const commAll = await db
+ .select()
+ .from(rfqComments)
+ .where(
+ and(
+ inArray(rfqComments.rfqId, distinctRfqs),
+ or(
+ isNull(rfqComments.vendorId),
+ eq(rfqComments.vendorId, vendorId)
+ )
+ )
+ );
+
+
+ // 3-E) 협력업체 응답 상세 - 기술
+ const technicalResponsesAll = await db
+ .select()
+ .from(vendorTechnicalResponses)
+ .where(
+ inArray(
+ vendorTechnicalResponses.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 3-F) 협력업체 응답 상세 - 상업
+ const commercialResponsesAll = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(
+ inArray(
+ vendorCommercialResponses.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 3-G) 협력업체 응답 첨부 파일
+ const responseAttachmentsAll = await db
+ .select()
+ .from(vendorResponseAttachments)
+ .where(
+ inArray(
+ vendorResponseAttachments.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 4) 데이터 그룹화
+ // RFQ 아이템 그룹화
+ const itemsByRfqId = new Map<number, any[]>();
+ for (const it of itemsAll) {
+ if (!itemsByRfqId.has(it.rfqId)) {
+ itemsByRfqId.set(it.rfqId, []);
+ }
+ itemsByRfqId.get(it.rfqId)!.push({
+ id: it.id,
+ itemCode: it.itemCode,
+ itemList: it.itemList,
+ subItemList: it.subItemList,
+ quantity: it.quantity,
+ description: it.description,
+ uom: it.uom,
+ });
+ }
+
+ // RFQ 첨부 파일 그룹화
+ const attachByRfqId = new Map<number, any[]>();
+ for (const att of attachAll) {
+ const rid = att.rfqId!;
+ if (!attachByRfqId.has(rid)) {
+ attachByRfqId.set(rid, []);
+ }
+ attachByRfqId.get(rid)!.push({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ vendorId: att.vendorId,
+ evaluationId: att.evaluationId,
+ });
+ }
+
+ // RFQ 코멘트 그룹화
+ const commByRfqId = new Map<number, any[]>();
+ for (const c of commAll) {
+ const rid = c.rfqId!;
+ if (!commByRfqId.has(rid)) {
+ commByRfqId.set(rid, []);
+ }
+ commByRfqId.get(rid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ });
+ }
+
+
+ // 기술 응답 그룹화
+ const techResponseByResponseId = new Map<number, any>();
+ for (const tr of technicalResponsesAll) {
+ techResponseByResponseId.set(tr.responseId, {
+ id: tr.id,
+ summary: tr.summary,
+ notes: tr.notes,
+ createdAt: tr.createdAt,
+ updatedAt: tr.updatedAt,
+ });
+ }
+
+ // 상업 응답 그룹화
+ const commResponseByResponseId = new Map<number, any>();
+ for (const cr of commercialResponsesAll) {
+ commResponseByResponseId.set(cr.responseId, {
+ id: cr.id,
+ totalPrice: cr.totalPrice,
+ currency: cr.currency,
+ paymentTerms: cr.paymentTerms,
+ incoterms: cr.incoterms,
+ deliveryPeriod: cr.deliveryPeriod,
+ warrantyPeriod: cr.warrantyPeriod,
+ validityPeriod: cr.validityPeriod,
+ priceBreakdown: cr.priceBreakdown,
+ commercialNotes: cr.commercialNotes,
+ createdAt: cr.createdAt,
+ updatedAt: cr.updatedAt,
+ });
+ }
+
+ // 응답 첨부 파일 그룹화
+ const respAttachByResponseId = new Map<number, any[]>();
+ for (const ra of responseAttachmentsAll) {
+ const rid = ra.responseId!;
+ if (!respAttachByResponseId.has(rid)) {
+ respAttachByResponseId.set(rid, []);
+ }
+ respAttachByResponseId.get(rid)!.push({
+ id: ra.id,
+ fileName: ra.fileName,
+ filePath: ra.filePath,
+ attachmentType: ra.attachmentType,
+ description: ra.description,
+ uploadedAt: ra.uploadedAt,
+ uploadedBy: ra.uploadedBy,
+ });
+ }
+
+ // 5) 최종 데이터 결합
+ const final = rows.map((row) => {
+ return {
+ // 응답 정보
+ responseId: row.responseId,
+ responseStatus: row.responseStatus,
+ respondedAt: row.respondedAt,
+
+ // RFQ 기본 정보
+ rfqId: row.rfqId,
+ rfqCode: row.rfqCode,
+ rfqDescription: row.rfqDescription,
+ rfqDueDate: row.rfqDueDate,
+ rfqStatus: row.rfqStatus,
+
+ rfqCreatedAt: row.rfqCreatedAt,
+ rfqUpdatedAt: row.rfqUpdatedAt,
+ rfqCreatedBy: row.rfqCreatedBy,
+
+ // 프로젝트 정보
+ projectId: row.projectId,
+ projectCode: row.projectCode,
+ projectName: row.projectName,
+
+ // 협력업체 정보
+ vendorId: row.vendorId,
+ vendorName: row.vendorName,
+ vendorCode: row.vendorCode,
+
+ // RFQ 관련 데이터
+ items: itemsByRfqId.get(row.rfqId) || [],
+ attachments: attachByRfqId.get(row.rfqId) || [],
+ comments: commByRfqId.get(row.rfqId) || [],
+
+ // 평가 정보
+ tbeEvaluation: row.tbeId ? {
+ id: row.tbeId,
+ result: row.tbeResult,
+ } : null,
+ cbeEvaluation: row.cbeId ? {
+ id: row.cbeId,
+ result: row.cbeResult,
+ } : null,
+
+ // 협력업체 응답 상세
+ technicalResponse: techResponseByResponseId.get(row.responseId) || null,
+ commercialResponse: commResponseByResponseId.get(row.responseId) || null,
+ responseAttachments: respAttachByResponseId.get(row.responseId) || [],
+
+ // 응답 상태 표시
+ hasTechnicalResponse: row.hasTechnicalResponse,
+ hasCommercialResponse: row.hasCommercialResponse,
+ attachmentCount: row.attachmentCount || 0,
+ };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data: final, pageCount };
+ },
+ [JSON.stringify(input), `${vendorId}`],
+ {
+ revalidate: 600,
+ tags: ["rfqs-vendor", `vendor-${vendorId}`],
+ }
+ )();
+}
+
+
+export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> {
+ try {
+ if (!rfqId || isNaN(Number(rfqId))) {
+ return {
+ success: false,
+ error: "Invalid RFQ ID provided",
+ }
+ }
+
+ // Query the database to get all items for the given RFQ ID
+ const items = await db
+ .select()
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+ .orderBy(rfqItems.itemCode)
+
+
+ return {
+ success: true,
+ data: items as ItemData[],
+ }
+ } catch (error) {
+ console.error("Error fetching RFQ items:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items",
+ }
+ }
+}
+
+
+// Define the schema for validation
+const commercialResponseSchema = z.object({
+ responseId: z.number(),
+ vendorId: z.number(), // Added vendorId field
+ responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
+ totalPrice: z.number().optional(),
+ currency: z.string().default("USD"),
+ paymentTerms: z.string().optional(),
+ incoterms: z.string().optional(),
+ deliveryPeriod: z.string().optional(),
+ warrantyPeriod: z.string().optional(),
+ validityPeriod: z.string().optional(),
+ priceBreakdown: z.string().optional(),
+ commercialNotes: z.string().optional(),
+})
+
+type CommercialResponseInput = z.infer<typeof commercialResponseSchema>
+
+interface ResponseType {
+ success: boolean
+ error?: string
+ data?: any
+}
+
+export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> {
+ try {
+ // Validate input data
+ const validated = commercialResponseSchema.parse(input)
+
+ // Check if a commercial response already exists for this responseId
+ const existingResponse = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(eq(vendorCommercialResponses.responseId, validated.responseId))
+ .limit(1)
+
+ const now = new Date()
+
+ if (existingResponse.length > 0) {
+ // Update existing record
+ await db
+ .update(vendorCommercialResponses)
+ .set({
+ responseStatus: validated.responseStatus,
+ totalPrice: validated.totalPrice,
+ currency: validated.currency,
+ paymentTerms: validated.paymentTerms,
+ incoterms: validated.incoterms,
+ deliveryPeriod: validated.deliveryPeriod,
+ warrantyPeriod: validated.warrantyPeriod,
+ validityPeriod: validated.validityPeriod,
+ priceBreakdown: validated.priceBreakdown,
+ commercialNotes: validated.commercialNotes,
+ updatedAt: now,
+ })
+ .where(eq(vendorCommercialResponses.responseId, validated.responseId))
+
+ } else {
+ // Return error instead of creating a new record
+ return {
+ success: false,
+ error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다."
+ }
+ }
+
+ // Also update the main vendor response status if submitted
+ if (validated.responseStatus === "SUBMITTED") {
+ // Get the vendor response
+ const vendorResponseResult = await db
+ .select()
+ .from(vendorResponses)
+ .where(eq(vendorResponses.id, validated.responseId))
+ .limit(1)
+
+ if (vendorResponseResult.length > 0) {
+ // Update the main response status to RESPONDED
+ await db
+ .update(vendorResponses)
+ .set({
+ responseStatus: "RESPONDED",
+ updatedAt: now,
+ })
+ .where(eq(vendorResponses.id, validated.responseId))
+ }
+ }
+
+ // Use vendorId for revalidateTag
+ revalidateTag(`cbe-vendor-${validated.vendorId}`)
+
+ return {
+ success: true,
+ data: { responseId: validated.responseId }
+ }
+
+ } catch (error) {
+ console.error("Error updating commercial response:", error)
+
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "유효하지 않은 데이터가 제공되었습니다."
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred"
+ }
+ }
+}
+// Helper function to get responseId from rfqId and vendorId
+export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> {
+ try {
+ const response = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(eq(vendorCommercialResponses.responseId, responseId))
+ .limit(1)
+
+ return response.length > 0 ? response[0] : null
+ } catch (error) {
+ console.error("Error getting commercial response:", error)
+ return null
+ }
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/types.ts b/lib/tech-vendor-rfq-response/types.ts
new file mode 100644
index 00000000..f8ae1fcf
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/types.ts
@@ -0,0 +1,76 @@
+// RFQ 아이템 타입
+export interface RfqResponseItem {
+ id: number;
+ itemCode: string;
+ itemList?: string | null;
+ subItemList?: string | null;
+ quantity?: number;
+ uom?: string;
+ description?: string | null;
+}
+
+// RFQ 첨부 파일 타입
+export interface RfqResponseAttachment {
+ id: number;
+ fileName: string;
+ filePath: string;
+ vendorId?: number | null;
+ evaluationId?: number | null;
+}
+
+// RFQ 코멘트 타입
+export interface RfqResponseComment {
+ id: number;
+ commentText: string;
+ vendorId?: number | null;
+ evaluationId?: number | null;
+ createdAt: Date;
+ commentedBy?: number;
+}
+
+// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화
+export interface RfqResponse {
+ // 응답 정보
+ responseId: number;
+ responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED";
+ respondedAt: Date;
+
+ // RFQ 기본 정보
+ rfqId: number;
+ rfqCode: string;
+ rfqDescription?: string | null;
+ rfqDueDate?: Date | null;
+ rfqStatus: string;
+ rfqCreatedAt: Date;
+ rfqUpdatedAt: Date;
+ rfqCreatedBy?: number | null;
+
+ // 프로젝트 정보
+ projectId?: number | null;
+ projectCode?: string | null;
+ projectName?: string | null;
+
+ // 협력업체 정보
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+
+ // RFQ 관련 데이터
+ items: RfqResponseItem[];
+ attachments: RfqResponseAttachment[];
+ comments: RfqResponseComment[];
+}
+
+// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입
+export interface RfqResponseWithId extends RfqResponse {
+ id: number; // rfqId와 동일하게 사용
+}
+
+// 페이지네이션 결과 타입
+export interface RfqResponsesResult {
+ data: RfqResponseWithId[];
+ pageCount: number;
+}
+
+// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지)
+export type RfqWithAll = RfqResponseWithId; \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
new file mode 100644
index 00000000..c7be0bf4
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
+import { toast } from "sonner"
+
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void
+ loadingVendors: Record<string, boolean>
+ openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void
+ // New prop for handling commercial response
+ openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openVendorContactsDialog,
+ openCommercialResponseSheet
+}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 그룹화(Nested) 컬럼 구성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {}
+
+ vendorResponseCbeColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<VendorWithCbeFields>
+ const childCol: ColumnDef<VendorWithCbeFields> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ maxSize: 120,
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
+ // Commercial Response Status에 배지 적용
+ if (cfg.id === "commercialResponseStatus") {
+ const status = val as string;
+
+ if (!status) return <span className="text-muted-foreground">-</span>;
+
+ let variant: "default" | "outline" | "secondary" | "destructive" = "outline";
+
+ switch (status) {
+ case "SUBMITTED":
+ variant = "default"; // Green
+ break;
+ case "IN_PROGRESS":
+ variant = "secondary"; // Orange/Yellow
+ break;
+ case "PENDING":
+ variant = "outline"; // Gray
+ break;
+ default:
+ variant = "outline";
+ }
+
+ return (
+ <Badge variant={variant} className="capitalize">
+ {status.toLowerCase().replace("_", " ")}
+ </Badge>
+ );
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<VendorWithCbeFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 3) Respond 컬럼 (새로 추가)
+ // ----------------------------------------------------------------
+ const respondColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "respond",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const responseId = vendor.responseId
+
+ if (!responseId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ const handleClick = () => {
+ openCommercialResponseSheet(responseId, vendor)
+ }
+
+ // Status에 따라 버튼 variant 변경
+ let variant: "default" | "outline" | "ghost" | "secondary" = "default"
+ let buttonText = "Respond"
+
+ if (vendor.commercialResponseStatus === "SUBMITTED") {
+ variant = "outline"
+ buttonText = "Update"
+ } else if (vendor.commercialResponseStatus === "IN_PROGRESS") {
+ variant = "secondary"
+ buttonText = "Continue"
+ }
+
+ return (
+ <Button
+ variant={variant}
+ size="sm"
+ // className="w-20"
+ onClick={handleClick}
+ >
+ <FileEdit className="h-3.5 w-3.5 mr-1" />
+ {buttonText}
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 200,
+ minSize: 115,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) Comments 컬럼
+ // ----------------------------------------------------------------
+ const commentsColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.length ?? 0
+
+ function handleClick() {
+ // rowAction + openCommentSheet
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(vendor.responseId ?? 0)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "attachDownload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Attach Download" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const files = vendor.files?.length || 0
+
+ if (!vendorId || !rfqId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용)
+ const rowKey = `${vendorId}_${rfqId}`
+ const isRowLoading = loadingVendors[rowKey] === true
+
+ // 템플릿 파일이 없으면 다운로드 버튼 비활성화
+ const isDisabled = files <= 0 || isRowLoading
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={
+ isDisabled
+ ? undefined
+ : () => handleDownloadCbeFiles(vendorId, rfqId)
+ }
+ aria-label={
+ isRowLoading
+ ? "다운로드 중..."
+ : files > 0
+ ? `CBE 첨부 다운로드 (${files}개)`
+ : "다운로드할 파일 없음"
+ }
+ disabled={isDisabled}
+ >
+ {isRowLoading ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ )}
+
+ {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */}
+ {!isRowLoading && files > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {files}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {isRowLoading
+ ? "다운로드 중..."
+ : files > 0
+ ? `CBE 첨부 다운로드 (${files}개)`
+ : "다운로드할 파일 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열 (respondColumn 추가)
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ respondColumn, // 응답 컬럼 추가
+ downloadColumn,
+ commentsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
new file mode 100644
index 00000000..94e29a95
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./cbe-table-columns"
+import {
+ fetchRfqAttachmentsbyCommentId,
+ getCBEbyVendorId,
+ getFileFromRfqAttachmentsbyid,
+ fetchCbeFiles
+} from "../../rfqs-tech/service"
+import { useSession } from "next-auth/react"
+import { CbeComment, CommentSheet } from "./comments-sheet"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { toast } from "sonner"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
+import { CommercialResponseSheet } from "./respond-cbe-sheet"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getCBEbyVendorId>>,
+ ]
+ >
+}
+
+export function CbeVendorTable({ promises }: VendorsTableProps) {
+ const { data: session } = useSession()
+ const userVendorId = session?.user?.companyId
+ const userId = Number(session?.user?.id)
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
+ const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
+
+ // 개별 협력업체별 로딩 상태를 관리하는 맵
+ const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({})
+
+ const router = useRouter()
+
+ // 코멘트 관련 상태
+ const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 상업 응답 관련 상태
+ const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false)
+ const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null)
+
+ // RFQ 상세 관련 상태
+ const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false)
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null)
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.responseId))
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(responseId: number) {
+ setInitialComments([])
+
+ const comments = rowAction?.row.original.comments
+ const rfqId = rowAction?.row.original.rfqId
+
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: CbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ if(rfqId) {
+ setSelectedRfqIdForComments(rfqId)
+ }
+ setSelectedCbeId(responseId)
+ setCommentSheetOpen(true)
+ }
+
+ // 상업 응답 시트 열기
+ function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) {
+ setSelectedResponseId(responseId)
+ setSelectedRfq(rfq)
+ setCommercialResponseSheetOpen(true)
+ }
+
+ // RFQ 상세 대화상자 열기
+ function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) {
+ setSelectedRfqId(rfqId)
+ setSelectedRfqDetail(rfq)
+ setRfqDetailDialogOpen(true)
+ }
+
+ const handleDownloadCbeFiles = React.useCallback(
+ async (vendorId: number, rfqId: number) => {
+ // 고유 키 생성: vendorId_rfqId
+ const rowKey = `${vendorId}_${rfqId}`
+
+ // 해당 협력업체의 로딩 상태만 true로 설정
+ setLoadingVendors(prev => ({
+ ...prev,
+ [rowKey]: true
+ }))
+
+ try {
+ const { files, error } = await fetchCbeFiles(vendorId, rfqId);
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ if (files.length === 0) {
+ toast.warning("다운로드할 CBE 파일이 없습니다");
+ return;
+ }
+ // 순차적으로 파일 다운로드
+ for (const file of files) {
+ await downloadFile(file.id);
+ }
+ toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`);
+ } catch (error) {
+ toast.error("CBE 파일을 다운로드하는 데 실패했습니다");
+ console.error(error);
+ } finally {
+ // 해당 협력업체의 로딩 상태만 false로 되돌림
+ setLoadingVendors(prev => ({
+ ...prev,
+ [rowKey]: false
+ }))
+ }
+ },
+ []
+ );
+
+ const downloadFile = React.useCallback(async (fileId: number) => {
+ try {
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
+ if (error || !file) {
+ throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
+ }
+
+ const link = document.createElement("a");
+ link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ }, []);
+
+ // 응답 성공 후 데이터 갱신
+ const handleResponseSuccess = React.useCallback(() => {
+ // 필요한 경우 데이터 다시 가져오기
+ router.refresh()
+ }, [router]);
+
+ // getColumns() 호출 시 필요한 핸들러들 주입
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openVendorContactsDialog: openRfqDetailDialog,
+ openCommercialResponseSheet,
+ }),
+ [
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openRfqDetailDialog,
+ openCommercialResponseSheet
+ ]
+ );
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<VendorWithCbeFields>[] = []
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
+
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정
+ },
+ getRowId: (originalRow) => String(originalRow.responseId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+
+ {/* 코멘트 시트 */}
+ {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={selectedRfqIdForComments}
+ initialComments={initialComments}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ cbeId={selectedCbeId}
+ />
+ )}
+
+ {/* 상업 응답 시트 */}
+ {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && (
+ <CommercialResponseSheet
+ open={commercialResponseSheetOpen}
+ onOpenChange={setCommercialResponseSheetOpen}
+ responseId={selectedResponseId}
+ rfq={selectedRfq}
+ onSuccess={handleResponseSuccess}
+ />
+ )}
+
+ {/* RFQ 상세 대화상자 */}
+ {rfqDetailDialogOpen && selectedRfqId !== null && (
+ <RfqDeailDialog
+ isOpen={rfqDetailDialogOpen}
+ onOpenChange={setRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfqDetail}
+ />
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..6a92f4d9
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
@@ -0,0 +1,323 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs-tech/service"
+
+
+export interface CbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: CbeComment[]
+ currentUserId: number
+ rfqId: number
+ tbeId?: number
+ cbeId?: number
+ vendorId: number
+ onCommentsUpdated?: (comments: CbeComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ tbeId,
+ cbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+
+ const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: cbeId,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: CbeComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
new file mode 100644
index 00000000..8cc4fa6f
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
@@ -0,0 +1,427 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Textarea } from "@/components/ui/textarea"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service"
+
+// Define schema for form validation (client-side)
+const commercialResponseFormSchema = z.object({
+ responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
+ totalPrice: z.coerce.number().optional(),
+ currency: z.string().default("USD"),
+ paymentTerms: z.string().optional(),
+ incoterms: z.string().optional(),
+ deliveryPeriod: z.string().optional(),
+ warrantyPeriod: z.string().optional(),
+ validityPeriod: z.string().optional(),
+ priceBreakdown: z.string().optional(),
+ commercialNotes: z.string().optional(),
+})
+
+type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema>
+
+interface CommercialResponseSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfq: VendorWithCbeFields | null
+ responseId: number | null // This is the vendor_responses.id
+ onSuccess?: () => void
+}
+
+export function CommercialResponseSheet({
+ rfq,
+ responseId,
+ onSuccess,
+ ...props
+}: CommercialResponseSheetProps) {
+ const [isSubmitting, startSubmitTransition] = React.useTransition()
+ const [isLoading, setIsLoading] = React.useState(true)
+
+ const form = useForm<CommercialResponseFormInput>({
+ resolver: zodResolver(commercialResponseFormSchema),
+ defaultValues: {
+ responseStatus: "PENDING",
+ totalPrice: undefined,
+ currency: "USD",
+ paymentTerms: "",
+ incoterms: "",
+ deliveryPeriod: "",
+ warrantyPeriod: "",
+ validityPeriod: "",
+ priceBreakdown: "",
+ commercialNotes: "",
+ },
+ })
+
+ // Load existing commercial response data when sheet opens
+ React.useEffect(() => {
+ async function loadCommercialResponse() {
+ if (!responseId) return
+
+ setIsLoading(true)
+ try {
+ // Use the helper function to get existing data
+ const existingResponse = await getCommercialResponseByResponseId(responseId)
+
+ if (existingResponse) {
+ // If we found existing data, populate the form
+ form.reset({
+ responseStatus: existingResponse.responseStatus,
+ totalPrice: existingResponse.totalPrice,
+ currency: existingResponse.currency || "USD",
+ paymentTerms: existingResponse.paymentTerms || "",
+ incoterms: existingResponse.incoterms || "",
+ deliveryPeriod: existingResponse.deliveryPeriod || "",
+ warrantyPeriod: existingResponse.warrantyPeriod || "",
+ validityPeriod: existingResponse.validityPeriod || "",
+ priceBreakdown: existingResponse.priceBreakdown || "",
+ commercialNotes: existingResponse.commercialNotes || "",
+ })
+ } else if (rfq) {
+ // If no existing data but we have rfq data with some values already
+ form.reset({
+ responseStatus: rfq.commercialResponseStatus as any || "PENDING",
+ totalPrice: rfq.totalPrice || undefined,
+ currency: rfq.currency || "USD",
+ paymentTerms: rfq.paymentTerms || "",
+ incoterms: rfq.incoterms || "",
+ deliveryPeriod: rfq.deliveryPeriod || "",
+ warrantyPeriod: rfq.warrantyPeriod || "",
+ validityPeriod: rfq.validityPeriod || "",
+ priceBreakdown: "",
+ commercialNotes: "",
+ })
+ }
+ } catch (error) {
+ console.error("Failed to load commercial response data:", error)
+ toast.error("상업 응답 데이터를 불러오는데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadCommercialResponse()
+ }, [responseId, rfq, form])
+
+ function onSubmit(formData: CommercialResponseFormInput) {
+ if (!responseId) {
+ toast.error("응답 ID를 찾을 수 없습니다")
+ return
+ }
+
+ if (!rfq?.vendorId) {
+ toast.error("협력업체 ID를 찾을 수 없습니다")
+ return
+ }
+
+ startSubmitTransition(async () => {
+ try {
+ // Pass both responseId and vendorId to the server action
+ const result = await updateCommercialResponse({
+ responseId,
+ vendorId: rfq.vendorId, // Include vendorId for revalidateTag
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast.error(result.error || "응답 제출 중 오류가 발생했습니다")
+ return
+ }
+
+ toast.success("Commercial response successfully submitted")
+ props.onOpenChange?.(false)
+
+ if (onSuccess) {
+ onSuccess()
+ }
+ } catch (error) {
+ console.error("Error submitting response:", error)
+ toast.error("응답 제출 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Commercial Response</SheetTitle>
+ <SheetDescription>
+ {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>}
+ <div className="mt-1">Please provide your commercial response for this RFQ</div>
+ </SheetDescription>
+ </SheetHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2"
+ >
+ <FormField
+ control={form.control}
+ name="responseStatus"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Response Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select response status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PENDING">Pending</SelectItem>
+ <SelectItem value="IN_PROGRESS">In Progress</SelectItem>
+ <SelectItem value="SUBMITTED">Submitted</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="totalPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Total Price</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ placeholder="0.00"
+ {...field}
+ value={field.value || ''}
+ onChange={(e) => {
+ const value = e.target.value === '' ? undefined : parseFloat(e.target.value);
+ field.onChange(value);
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Currency</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select currency" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="GBP">GBP</SelectItem>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* Other form fields remain the same */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Payment Terms</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. Net 30" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Incoterms</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value || ''}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select incoterms" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="EXW">EXW (Ex Works)</SelectItem>
+ <SelectItem value="FCA">FCA (Free Carrier)</SelectItem>
+ <SelectItem value="FOB">FOB (Free On Board)</SelectItem>
+ <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem>
+ <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem>
+ <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="deliveryPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Delivery Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 4-6 weeks" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="warrantyPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Warranty Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 12 months" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Validity Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 30 days" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="priceBreakdown"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Price Breakdown (Optional)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter price breakdown details here"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="commercialNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Additional Notes (Optional)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Any additional comments or notes"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-4 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isSubmitting} type="submit">
+ {isSubmitting && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Submit Response
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..a6ec6072
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { RfqItemsTable } from "./rfq-items-table/rfq-items-table"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: VendorWithCbeFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+
+
+
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ RFQ 상태: {rfq.rfqStatus}
+ </Badge>
+ )}
+
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
new file mode 100644
index 00000000..bf4ae709
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
@@ -0,0 +1,62 @@
+"use client"
+// Because columns rely on React state/hooks for row actions
+
+import * as React from "react"
+import { ColumnDef, Row } from "@tanstack/react-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ItemData } from "./rfq-items-table"
+
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns(): ColumnDef<ItemData>[] {
+ return [
+
+ // Vendor Name
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Item Code" />
+ ),
+ cell: ({ row }) => row.getValue("itemCode"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.getValue("description"),
+ },
+
+ // Status
+ {
+ accessorKey: "quantity",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Quantity" />
+ ),
+ cell: ({ row }) => row.getValue("quantity"),
+ },
+
+
+ // Created At
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+
+ // Updated At
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
new file mode 100644
index 00000000..c5c67e54
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { getColumns } from "./rfq-items-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { Loader2 } from "lucide-react"
+import { useToast } from "@/hooks/use-toast"
+import { getItemsByRfqId } from "../../service"
+
+export interface ItemData {
+ id: number
+ itemCode: string
+ description: string | null
+ quantity: number
+ uom: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface RFQItemsTableProps {
+ rfqId: number
+}
+
+export function RfqItemsTable({ rfqId }: RFQItemsTableProps) {
+ const { toast } = useToast()
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const [rfqItems, setRfqItems] = React.useState<ItemData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadItems() {
+ setIsLoading(true)
+ try {
+ // Use the correct function name (camelCase)
+ const result = await getItemsByRfqId(rfqId)
+ if (result.success && result.data) {
+ setRfqItems(result.data as ItemData[])
+ } else {
+ throw new Error(result.error || "Unknown error occurred")
+ }
+ } catch (error) {
+ console.error("RFQ 아이템 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load RFQ items",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadItems()
+ }, [toast, rfqId])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [
+ { id: "itemCode", label: "Item Code", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "quantity", label: "Quantity", type: "number" },
+ { id: "uom", label: "UoM", type: "text" },
+ ]
+
+ // If loading, show a flex container that fills the parent and centers the spinner
+ if (isLoading) {
+ return (
+ <div className="flex h-full w-full items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ )
+ }
+
+ // Otherwise, show the table
+ return (
+ <ClientDataTable
+ data={rfqItems}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ </ClientDataTable>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
new file mode 100644
index 00000000..da656356
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { RfqWithAll } from "../types"
+/**
+ * 아이템 구조 예시
+ * - API 응답에서 quantity가 "string" 형태이므로,
+ * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다.
+ */
+export interface RfqItem {
+ id: number
+ itemCode: string
+ itemName: string
+ itemList: string | null
+ subItemList: string | null
+ quantity: string
+ description: string
+ uom: string
+}
+
+/**
+ * 첨부파일 구조 예시
+ */
+export interface RfqAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ vendorId: number | null
+ evaluationId: number | null
+}
+
+
+/**
+ * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용)
+ */
+export interface DefaultItem {
+ id?: number
+ itemCode: string
+ description?: string | null
+ quantity?: number | null
+ uom?: string | null
+}
+
+/**
+ * RfqsItemsDialog 컴포넌트 Prop 타입
+ */
+export interface RfqsItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rfq: RfqWithAll
+ defaultItems?: DefaultItem[]
+}
+
+export function RfqsItemsDialog({
+ open,
+ onOpenChange,
+ rfq,
+}: RfqsItemsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle>
+ <DialogDescription>
+ Below is the list of items for this RFQ.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-x-auto w-full space-y-4">
+ {rfq && rfq.items.length === 0 && (
+ <p className="text-sm text-muted-foreground">No items found.</p>
+ )}
+ {rfq && rfq.items.length > 0 && (
+ <Table>
+ {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */}
+ <TableHeader>
+ <TableRow>
+ <TableHead>Item Code</TableHead>
+ <TableHead>Item List</TableHead>
+ <TableHead>Sub Item List</TableHead>
+ <TableHead>Description</TableHead>
+ <TableHead>Qty</TableHead>
+ <TableHead>UoM</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {rfq.items.map((it, idx) => (
+ <TableRow key={it.id ?? idx}>
+ <TableCell>{it.itemCode || "No Code"}</TableCell>
+ <TableCell>{it.itemList || "-"}</TableCell>
+ <TableCell>{it.subItemList || "-"}</TableCell>
+ <TableCell>{it.description || "-"}</TableCell>
+ <TableCell>{it.quantity ?? 1}</TableCell>
+ <TableCell>{it.uom ?? "each"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </div>
+
+ <DialogFooter className="mt-4">
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
new file mode 100644
index 00000000..6c51c12c
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
@@ -0,0 +1,106 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+
+// 첨부파일 구조
+interface RfqAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date // or Date
+ vendorId?: number | null
+ size?: number
+}
+
+// 컴포넌트 Prop
+interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfqId: number
+ attachments?: RfqAttachment[]
+}
+
+/**
+ * RfqAttachmentsSheet:
+ * - 단순히 첨부파일 리스트 + 다운로드 버튼만
+ */
+export function RfqAttachmentsSheet({
+ rfqId,
+ attachments = [],
+ ...props
+}: RfqAttachmentsSheetProps) {
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-sm">
+ <SheetHeader>
+ <SheetTitle>Attachments</SheetTitle>
+ <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-2">
+ {/* 첨부파일이 없을 경우 */}
+ {attachments.length === 0 && (
+ <p className="text-sm text-muted-foreground">
+ No attachments
+ </p>
+ )}
+
+ {/* 첨부파일 목록 */}
+ {attachments.map((att) => (
+ <div
+ key={att.id}
+ className="flex items-center justify-between rounded border p-2"
+ >
+ <div className="flex flex-col text-sm">
+ <span className="font-medium">{att.fileName}</span>
+ {att.size && (
+ <span className="text-xs text-muted-foreground">
+ {Math.round(att.size / 1024)} KB
+ </span>
+ )}
+ {att.createdAt && (
+ <span className="text-xs text-muted-foreground">
+ Created at {formatDate(att.createdAt)}
+ </span>
+ )}
+ </div>
+ {/* 파일 다운로드 버튼 */}
+ {att.filePath && (
+ <a
+ href={att.filePath}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="text-sm"
+ >
+ <Button variant="ghost" size="icon" type="button">
+ <Download className="h-4 w-4" />
+ </Button>
+ </a>
+ )}
+ </div>
+ ))}
+ </div>
+
+ <SheetFooter className="gap-2 pt-2">
+ {/* 닫기 버튼 */}
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Close
+ </Button>
+ </SheetClose>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
new file mode 100644
index 00000000..8904fcff
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
@@ -0,0 +1,320 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs-tech/service"
+
+
+export interface MatchedVendorComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: MatchedVendorComment[]
+ currentUserId: number
+ rfqId: number
+ vendorId: number
+ onCommentsUpdated?: (comments: MatchedVendorComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+ console.log(initialComments)
+
+ const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: null,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: MatchedVendorComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
new file mode 100644
index 00000000..69a5e7e7
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
@@ -0,0 +1,424 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { ColumnDef } from "@tanstack/react-table"
+import {
+ Ellipsis,
+ MessageSquare,
+ Package,
+ Paperclip,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Badge } from "@/components/ui/badge"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { modifyRfqVendor } from "../../rfqs-tech/service"
+import type { RfqWithAll } from "../types"
+import type { DataTableRowAction } from "@/types/table"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<RfqWithAll> | null>
+ >
+ router: NextRouter
+ openAttachmentsSheet: (rfqId: number) => void
+ openCommentSheet: (rfqId: number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (Nested Header)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openAttachmentsSheet,
+ openCommentSheet,
+}: GetColumnsProps): ColumnDef<RfqWithAll>[] {
+ // 1) 체크박스(Select) 컬럼
+ const selectColumn: ColumnDef<RfqWithAll> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // 2) Actions (Dropdown)
+ const actionsColumn: ColumnDef<RfqWithAll> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <Ellipsis className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.responseStatus}
+ onValueChange={(value) => {
+ startUpdateTransition(async () => {
+ let newStatus:
+ | "ACCEPTED"
+ | "DECLINED"
+ | "REVIEWING"
+
+ switch (value) {
+ case "ACCEPTED":
+ newStatus = "ACCEPTED"
+ break
+ case "DECLINED":
+ newStatus = "DECLINED"
+ break
+ default:
+ newStatus = "REVIEWING"
+ }
+
+ await toast.promise(
+ modifyRfqVendor({
+ id: row.original.responseId,
+ status: newStatus,
+ }),
+ {
+ loading: "Updating response status...",
+ success: "Response status updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {[
+ { value: "ACCEPTED", label: "Accept RFQ" },
+ { value: "DECLINED", label: "Decline RFQ" },
+ ].map((rep) => (
+ <DropdownMenuRadioItem
+ key={rep.value}
+ value={rep.value}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {rep.label}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ {/* <DropdownMenuItem
+ onClick={() => {
+ router.push(`/vendor/rfqs/${row.original.rfqId}`)
+ }}
+ >
+ View Details
+ </DropdownMenuItem> */}
+ {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}>
+ View Attachments
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}>
+ View Comments
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}>
+ View Items
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // 3) RFQ Code 컬럼
+ const rfqCodeColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqCode",
+ accessorKey: "rfqCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
+ ),
+ // cell: ({ row }) => {
+ // return (
+ // <Button
+ // variant="link"
+ // className="p-0 h-auto font-medium"
+ // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)}
+ // >
+ // {row.original.rfqCode}
+ // </Button>
+ // )
+ // },
+ cell: ({ row }) => row.original.rfqCode || "-",
+ size: 150,
+ }
+
+
+
+ // 4) 응답 상태 컬럼
+ const responseStatusColumn: ColumnDef<RfqWithAll> = {
+ id: "responseStatus",
+ accessorKey: "responseStatus",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.responseStatus;
+ let variant: "default" | "secondary" | "destructive" | "outline";
+
+ switch (status) {
+ case "REVIEWING":
+ variant = "default";
+ break;
+ case "ACCEPTED":
+ variant = "secondary";
+ break;
+ case "DECLINED":
+ variant = "destructive";
+ break;
+ default:
+ variant = "outline";
+ }
+
+ return <Badge variant={variant}>{status}</Badge>;
+ },
+ size: 150,
+ }
+
+ // 5) 프로젝트 이름 컬럼
+ const projectNameColumn: ColumnDef<RfqWithAll> = {
+ id: "projectName",
+ accessorKey: "projectName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Project" />
+ ),
+ cell: ({ row }) => row.original.projectName || "-",
+ size: 150,
+ }
+
+ // 6) RFQ Description 컬럼
+ const descriptionColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqDescription",
+ accessorKey: "rfqDescription",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.original.rfqDescription || "-",
+ size: 200,
+ }
+
+ // 7) Due Date 컬럼
+ const dueDateColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqDueDate",
+ accessorKey: "rfqDueDate",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Due Date" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.rfqDueDate;
+ return date ? formatDate(date) : "-";
+ },
+ size: 120,
+ }
+
+ // 8) Last Updated 컬럼
+ const updatedAtColumn: ColumnDef<RfqWithAll> = {
+ id: "respondedAt",
+ accessorKey: "respondedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Last Updated" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.respondedAt;
+ return date ? formatDateTime(date) : "-";
+ },
+ size: 150,
+ }
+
+ // 9) Items 컬럼 - 뱃지로 아이템 개수 표시
+ const itemsColumn: ColumnDef<RfqWithAll> = {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Items" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const count = rfq.items?.length ?? 0
+
+ function handleClick() {
+ setRowAction({ row, type: "items" })
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={count > 0 ? `View ${count} items` : "No items"}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {count > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {count}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {count > 0 ? `${count} Items` : "No Items"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시
+ const attachmentsColumn: ColumnDef<RfqWithAll> = {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Attachments" />
+ ),
+ cell: ({ row }) => {
+ const attachCount = row.original.attachments?.length ?? 0
+
+ function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
+ e.preventDefault()
+ openAttachmentsSheet(row.original.rfqId)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachCount > 0 ? `View ${attachCount} files` : "No files"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {attachCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {attachCount > 0 ? `${attachCount} Files` : "No Files"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시
+ const commentsColumn: ColumnDef<RfqWithAll> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const commCount = row.original.comments?.length ?? 0
+
+ function handleClick() {
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(row.original.rfqId)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외
+ return [
+ selectColumn,
+ rfqCodeColumn,
+ responseStatusColumn,
+ projectNameColumn,
+ descriptionColumn,
+ dueDateColumn,
+ itemsColumn,
+ attachmentsColumn,
+ commentsColumn,
+ updatedAtColumn,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
new file mode 100644
index 00000000..1bae99ef
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { RfqWithAll } from "../types"
+
+
+interface RfqsTableToolbarActionsProps {
+ table: Table<RfqWithAll>
+}
+
+export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) {
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tasks",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
new file mode 100644
index 00000000..2e5ae5dc
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
@@ -0,0 +1,280 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { useRouter } from "next/navigation"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./rfqs-table-columns"
+import { RfqWithAll } from "../types"
+
+import {
+ fetchRfqAttachments,
+ fetchRfqAttachmentsbyCommentId,
+} from "../../rfqs-tech/service"
+
+import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions"
+import { RfqsItemsDialog } from "./ItemsDialog"
+import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
+import { CommentSheet } from "./comments-sheet"
+import { getRfqResponsesForVendor } from "../service"
+import { useSession } from "next-auth/react" // Next-auth session hook 추가
+
+interface RfqsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]>
+}
+
+// 코멘트+첨부파일 구조 예시
+export interface RfqCommentWithAttachments {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+export interface ExistingAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date
+ vendorId?: number | null
+ size?: number
+}
+
+export interface ExistingItem {
+ id?: number
+ itemCode: string
+ description: string | null
+ quantity: number | null
+ uom: string | null
+}
+
+export function RfqsVendorTable({ promises }: RfqsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ // 1) 테이블 데이터( RFQs )
+ const [{ data: responseData, pageCount }] = React.use(promises)
+
+ // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가)
+ const data: RfqWithAll[] = React.useMemo(() => {
+ return responseData.map(item => ({
+ ...item,
+ id: item.rfqId, // id 필드를 rfqId와 동일하게 설정
+ }));
+ }, [responseData]);
+
+ const router = useRouter()
+
+ // 2) 첨부파일 시트 + 관련 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null)
+ const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([])
+
+ // 3) 코멘트 시트 + 관련 상태
+ const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 4) rowAction으로 다양한 모달/시트 열기
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null)
+
+ // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리
+ React.useEffect(() => {
+ if (rowAction?.type === "comments" && rowAction?.row.original) {
+ openCommentSheet(rowAction.row.original.id)
+ }
+ }, [rowAction])
+
+ /**
+ * (A) 코멘트 시트를 열기 전에,
+ * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회.
+ */
+ const openCommentSheet = React.useCallback(async (rfqId: number) => {
+ setInitialComments([])
+
+ // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기
+ const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || []
+
+ if (comments && comments.length > 0) {
+ const commentWithAttachments = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+ return {
+ ...c,
+ commentedBy: c.commentedBy || 1,
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(rfqId)
+ setCommentSheetOpen(true)
+ }, [data]) // data만 의존성으로 추가
+
+ /**
+ * (B) 첨부파일 시트 열기
+ */
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ const list = await fetchRfqAttachments(rfqId)
+ setAttachDefault(list)
+ setSelectedRfqIdForAttachments(rfqId)
+ setAttachmentsOpen(true)
+ }, [])
+
+ // 5) DataTable 컬럼 세팅
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ setRowAction,
+ router,
+ openAttachmentsSheet,
+ openCommentSheet
+ }),
+ [setRowAction, router, openAttachmentsSheet, openCommentSheet]
+ )
+
+ /**
+ * 간단한 filterFields 예시
+ */
+ const filterFields: DataTableFilterField<RfqWithAll>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ placeholder: "Filter RFQ Code...",
+ },
+ {
+ id: "projectName",
+ label: "Project",
+ placeholder: "Filter Project...",
+ },
+ {
+ id: "rfqDescription",
+ label: "Description",
+ placeholder: "Filter Description...",
+ },
+ ]
+
+ /**
+ * Advanced filter fields 예시
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ type: "text",
+ },
+ {
+ id: "rfqDescription",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "projectName",
+ label: "Project Name",
+ type: "text",
+ },
+ {
+ id: "rfqDueDate",
+ label: "Due Date",
+ type: "date",
+ },
+ {
+ id: "responseStatus",
+ label: "Response Status",
+ type: "select",
+ options: [
+ { label: "Reviewing", value: "REVIEWING" },
+ { label: "Accepted", value: "ACCEPTED" },
+ { label: "Declined", value: "DECLINED" },
+ ],
+ }
+ ]
+
+ // useDataTable() 훅 -> pagination, sorting 등 관리
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+ const currentVendorId = session?.user?.id ? session.user.companyId : 0
+
+
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RfqsVendorTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 1) 아이템 목록 Dialog */}
+ {rowAction?.type === "items" && rowAction?.row.original && (
+ <RfqsItemsDialog
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ rfq={rowAction.row.original}
+ />
+ )}
+
+ {/* 2) 코멘트 시트 */}
+ {selectedRfqIdForComments && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ initialComments={initialComments}
+ rfqId={selectedRfqIdForComments}
+ vendorId={currentVendorId??0}
+ currentUserId={currentUserId}
+ />
+ )}
+
+ {/* 3) 첨부파일 시트 */}
+ <RfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ rfqId={selectedRfqIdForAttachments ?? 0}
+ attachments={attachDefault}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..5e27a4aa
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
@@ -0,0 +1,348 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Textarea,
+} from "@/components/ui/textarea"
+
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone"
+
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell
+} from "@/components/ui/table"
+
+// DB 스키마에서 필요한 타입들을 가져온다고 가정
+// (실제 프로젝트에 맞춰 import를 수정하세요.)
+import { RfqWithAll } from "@/db/schema/rfq"
+import { createRfqCommentWithAttachments } from "../../rfqs-tech/service"
+import { formatDate } from "@/lib/utils"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface TbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: string | Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: TbeComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfqId:number
+ tbeId:number
+ vendorId:number
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+ isLoading?: boolean // New prop
+
+}
+
+// 새 코멘트 작성 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional() // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ tbeId,
+ currentUserId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+ const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+
+ // RHF 세팅
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {/* 첨부파일 표시 */}
+ {(!c.attachments || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={att.filePath}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
+ <TableCell>
+ {c.commentedBy ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: tbeId, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버에서 반환된 commentId
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: new Date().toISOString(),
+ attachments: (data.newFiles?.map((f, idx) => ({
+ id: Math.random() * 100000,
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [])
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ // 폼 리셋
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 기존 코멘트 목록 */}
+ <div className="max-h-[300px] overflow-y-auto">
+ {renderExistingComments()}
+ </div>
+
+ {/* 새 코멘트 작성 Form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter your comment..."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Dropzone (파일 첨부) */}
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {/* 선택된 파일 목록 */}
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div key={field.id} className="flex items-center justify-between border rounded p-2">
+ <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..26698c2e
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,75 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table"
+import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: TbeVendorFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ {rfq.rfqStatus}
+ </Badge>
+ )}
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
new file mode 100644
index 00000000..b880506a
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,350 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, MessageSquare, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+
+import {
+ tbeVendorColumnsConfig,
+ VendorTbeColumnConfig,
+ vendorTbeColumnsConfig,
+ TbeVendorFields,
+} from "@/config/vendorTbeColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TbeVendorFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void
+ handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void
+ openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처
+
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ openVendorContactsDialog
+}: GetColumnsProps): ColumnDef<TbeVendorFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TbeVendorFields> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 그룹화(Nested) 컬럼 구성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<TbeVendorFields>[]> = {}
+
+ tbeVendorColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<TbeVendorFields>
+ const childCol: ColumnDef<TbeVendorFields> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ maxSize: 120,
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+ if (cfg.id === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline"
+ return (
+ <Badge variant={variant}>
+ {statusVal}
+ </Badge>
+ )
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "tbeUpdated") {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<TbeVendorFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 3) Comments 컬럼
+ // ----------------------------------------------------------------
+ const commentsColumn: ColumnDef<TbeVendorFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.filter(c => c.evaluationId === vendor.tbeId)?.length ?? 0
+
+ function handleClick() {
+ // rowAction + openCommentSheet
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(vendor.tbeId ?? 0)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능
+ // ----------------------------------------------------------------
+ const tbeDownloadColumn: ColumnDef<TbeVendorFields> = {
+ id: "tbeDownload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE Sheets" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const tbeId = vendor.tbeId
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const templateFileCount = vendor.templateFileCount || 0
+
+ if (!tbeId || !vendorId || !rfqId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ // 템플릿 파일이 없으면 다운로드 버튼 비활성화
+ const isDisabled = templateFileCount <= 0
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={
+ isDisabled
+ ? undefined
+ : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId)
+ }
+ aria-label={
+ templateFileCount > 0
+ ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
+ : "다운로드할 파일 없음"
+ }
+ disabled={isDisabled}
+ >
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+
+ {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */}
+ {templateFileCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {templateFileCount}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {templateFileCount > 0
+ ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
+ : "다운로드할 파일 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+ // ----------------------------------------------------------------
+ // 5) TBE 업로드 컬럼 - 응답 업로드 기능
+ // ----------------------------------------------------------------
+ const tbeUploadColumn: ColumnDef<TbeVendorFields> = {
+ id: "tbeUpload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Upload Response" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const tbeId = vendor.tbeId
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const vendorResponseId = vendor.vendorResponseId || 0
+ const status = vendor.rfqVendorStatus
+ const hasResponse = vendor.hasResponse || false
+
+
+ if (!tbeId || !vendorId || !rfqId || status === "REJECTED") {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ return (
+ <div >
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group relative"
+ onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)}
+ aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"}
+ >
+ <div className="flex items-center justify-center relative">
+ <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </div>
+ {hasResponse && (
+ <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span>
+ )}
+ <span className="sr-only">
+ {"TBE 응답 업로드"}
+ </span>
+ </Button>
+ </div>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ commentsColumn,
+ tbeDownloadColumn,
+ tbeUploadColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
new file mode 100644
index 00000000..2de2dd11
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { toast } from "sonner"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./tbe-table-columns"
+import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs-tech/service"
+import { CommentSheet, TbeComment } from "./comments-sheet"
+import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
+import { useTbeFileHandlers } from "./tbeFileHandler"
+import { useSession } from "next-auth/react"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTBEforVendor>>,
+ ]
+ >
+}
+
+export function TbeVendorTable({ promises }: VendorsTableProps) {
+ const { data: session } = useSession()
+ const userVendorId = session?.user?.companyId
+ const userId = Number(session?.user?.id)
+ console.log("userVendorId", userVendorId)
+ console.log("userId", userId)
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null)
+
+
+ // router 획득
+ const router = useRouter()
+
+ const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(0)
+ const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false)
+
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null)
+
+ const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => {
+ setSelectedRfqId(rfqId)
+ setSelectedRfq(rfq)
+ setIsRfqDetailDialogOpen(true)
+ }
+
+ // TBE 파일 핸들러 훅 사용
+ const {
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ UploadDialog,
+ } = useTbeFileHandlers()
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.tbeId))
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(tbeId: number) {
+ setInitialComments([])
+ setIsLoadingComments(true)
+
+ const comments = rowAction?.row.original.comments?.filter(c => c.evaluationId === tbeId)
+
+ try {
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: TbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(rowAction?.row.original.rfqId ?? null)
+ setCommentSheetOpen(true)
+
+ } catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
+ }
+}
+
+ // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ openVendorContactsDialog
+ }),
+ [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog]
+ )
+
+ const filterFields: DataTableFilterField<TbeVendorFields>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "tbeResult", label: "TBE Result", type: "text" },
+ { id: "tbeNote", label: "TBE Note", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "hasResponse", label: "Response?", type: "boolean" },
+ { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ { id: "dueDate", label: "Project Name", type: "date" },
+
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", desc: true }],
+ columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정
+ },
+ getRowId: (originalRow) => String(originalRow.rfqId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+
+ {/* 코멘트 시트 */}
+ {commentSheetOpen && selectedRfqIdForComments && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={selectedRfqIdForComments}
+ tbeId={rowAction?.row.original.tbeId || 0}
+ initialComments={initialComments}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ isLoading={isLoadingComments} // Pass the loading state
+
+ />
+ )}
+
+ <RfqDeailDialog
+ isOpen={isRfqDetailDialogOpen}
+ onOpenChange={setIsRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfq}
+ />
+
+ {/* TBE 파일 다이얼로그 */}
+ <UploadDialog />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
new file mode 100644
index 00000000..6c622fd1
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import { useCallback, useState, useEffect } from "react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ fetchTbeTemplateFiles,
+ uploadTbeResponseFile,
+ getTbeSubmittedFiles,
+ getFileFromRfqAttachmentsbyid,
+} from "../../rfqs-tech/service";
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
+import { Download, X } from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { formatDateTime } from "@/lib/utils";
+
+export function useTbeFileHandlers() {
+ // 모달 열림 여부, 현재 선택된 IDs
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
+ const [currentTbeId, setCurrentTbeId] = useState<number | null>(null);
+ const [currentVendorId, setCurrentVendorId] = useState<number | null>(null);
+ const [currentRfqId, setCurrentRfqId] = useState<number | null>(null);
+ const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null);
+
+
+
+ // 로딩 상태들
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFetchingFiles, setIsFetchingFiles] = useState(false);
+
+ // 업로드할 파일, 제출된 파일 목록
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
+ const [submittedFiles, setSubmittedFiles] = useState<
+ Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }>
+ >([]);
+
+ // ===================================
+ // 1) 제출된 파일 목록 가져오기
+ // ===================================
+ const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => {
+ if (!vendorResponseId ) return;
+
+ setIsFetchingFiles(true);
+ try {
+ const { files, error } = await getTbeSubmittedFiles(vendorResponseId);
+ if (error) {
+ console.error(error);
+ return;
+ }
+ setSubmittedFiles(files);
+ } catch (error) {
+ console.error("Failed to fetch submitted files:", error);
+ } finally {
+ setIsFetchingFiles(false);
+ }
+ }, []);
+
+ // ===================================
+ // 2) TBE 템플릿 다운로드
+ // ===================================
+ const handleDownloadTbeTemplate = useCallback(
+ async (tbeId: number, vendorId: number, rfqId: number) => {
+ setCurrentTbeId(tbeId);
+ setCurrentVendorId(vendorId);
+ setCurrentRfqId(rfqId);
+ setIsLoading(true);
+
+ try {
+ const { files, error } = await fetchTbeTemplateFiles(tbeId);
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ if (files.length === 0) {
+ toast.warning("다운로드할 템플릿 파일이 없습니다");
+ return;
+ }
+ // 순차적으로 파일 다운로드
+ for (const file of files) {
+ await downloadFile(file.id);
+ }
+ toast.success("모든 템플릿 파일이 다운로드되었습니다");
+ } catch (error) {
+ toast.error("템플릿 파일을 다운로드하는 데 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ []
+ );
+
+ // 실제 다운로드 로직
+ const downloadFile = useCallback(async (fileId: number) => {
+ try {
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
+ if (error || !file) {
+ throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
+ }
+
+ const link = document.createElement("a");
+ link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ }, []);
+
+ // ===================================
+ // 3) 제출된 파일 다운로드
+ // ===================================
+ const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => {
+ try {
+ const link = document.createElement("a");
+ link.href = `/api/tbe-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success(`${file.fileName} 다운로드 시작`);
+ } catch (error) {
+ console.error("Failed to download file:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ }, []);
+
+ // ===================================
+ // 4) TBE 응답 업로드 모달 열기
+ // (이 시점에서는 데이터 fetch하지 않음)
+ // ===================================
+ const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => {
+ setCurrentTbeId(tbeId);
+ setCurrentVendorId(vendorId);
+ setCurrentRfqId(rfqId);
+ setCurrentvendorResponseId(vendorResponseId);
+ setIsUploadDialogOpen(true);
+ }, []);
+
+ // ===================================
+ // 5) Dialog 열고 닫힐 때 상태 초기화
+ // 열렸을 때 -> useEffect로 파일 목록 가져오기
+ // ===================================
+ useEffect(() => {
+ if (!isUploadDialogOpen) {
+ // 닫힐 때는 파일 상태들 초기화
+ setSelectedFile(null);
+ setSubmittedFiles([]);
+ }
+ }, [isUploadDialogOpen]);
+
+ useEffect(() => {
+ // Dialog가 열렸고, ID들이 유효하면
+ if (isUploadDialogOpen &&currentvendorResponseId) {
+ fetchSubmittedFiles(currentvendorResponseId);
+ }
+ }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]);
+
+ // ===================================
+ // 6) 드롭존 파일 선택 & 제거
+ // ===================================
+ const handleFileDrop = useCallback((files: File[]) => {
+ if (files && files.length > 0) {
+ setSelectedFile(files[0]);
+ }
+ }, []);
+
+ const handleRemoveFile = useCallback(() => {
+ setSelectedFile(null);
+ }, []);
+
+ // ===================================
+ // 7) 응답 파일 업로드
+ // ===================================
+ const handleSubmitResponse = useCallback(async () => {
+ if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) {
+ toast.error("업로드할 파일을 선택해주세요");
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // FormData 생성
+ const formData = new FormData();
+ formData.append("file", selectedFile);
+ formData.append("rfqId", currentRfqId.toString());
+ formData.append("vendorId", currentVendorId.toString());
+ formData.append("evaluationId", currentTbeId.toString());
+ formData.append("vendorResponseId", currentvendorResponseId.toString());
+
+ const result = await uploadTbeResponseFile(formData);
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드에 실패했습니다");
+ }
+
+ toast.success(result.message || "응답이 성공적으로 업로드되었습니다");
+
+ // 업로드 후 다시 제출된 파일 목록 가져오기
+ await fetchSubmittedFiles(currentvendorResponseId);
+
+ // 업로드 성공 시 선택 파일 초기화
+ setSelectedFile(null);
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]);
+
+ // ===================================
+ // 8) 실제 Dialog 컴포넌트
+ // ===================================
+ const UploadDialog = () => (
+ <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>TBE 응답 파일</DialogTitle>
+ <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="upload" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="upload">새 파일 업로드</TabsTrigger>
+ <TabsTrigger
+ value="submitted"
+ disabled={submittedFiles.length === 0}
+ className={submittedFiles.length > 0 ? "relative" : ""}
+ >
+ 제출된 파일{" "}
+ {submittedFiles.length > 0 && (
+ <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground">
+ {submittedFiles.length}
+ </span>
+ )}
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 업로드 탭 */}
+ <TabsContent value="upload" className="pt-4">
+ <div className="grid gap-4">
+ {selectedFile ? (
+ <FileList>
+ <FileListItem>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{selectedFile.name}</FileListName>
+ <FileListSize>{selectedFile.size}</FileListSize>
+ </FileListInfo>
+ <FileListAction onClick={handleRemoveFile}>
+ <X className="h-4 w-4" />
+ <span className="sr-only">파일 제거</span>
+ </FileListAction>
+ </FileListItem>
+ </FileList>
+ ) : (
+ <Dropzone onDrop={handleFileDrop}>
+ <DropzoneInput className="sr-only" />
+ <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6">
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
+ <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription>
+ </DropzoneZone>
+ </Dropzone>
+ )}
+
+ <DialogFooter className="mt-4">
+ <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}>
+ {isLoading ? "업로드 중..." : "응답 업로드"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </TabsContent>
+
+ {/* 제출된 파일 탭 */}
+ <TabsContent value="submitted" className="pt-4">
+ {isFetchingFiles ? (
+ <div className="flex justify-center items-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : submittedFiles.length > 0 ? (
+ <div className="grid gap-2">
+ <FileList>
+ {submittedFiles.map((file) => (
+ <FileListItem key={file.id} className="flex items-center justify-between gap-3">
+ <div className="flex items-center gap-3 flex-1">
+ <FileListIcon className="flex-shrink-0" />
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
+ <FileListDescription className="text-xs text-muted-foreground">
+ {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""}
+ </FileListDescription>
+ </FileListInfo>
+ </div>
+ <FileListAction className="flex-shrink-0 ml-2" onClick={() => downloadSubmittedFile(file)}>
+ <Download className="h-4 w-4" />
+ <span className="sr-only">파일 다운로드</span>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
+ )}
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ );
+
+ // ===================================
+ // 9) Hooks 내보내기
+ // ===================================
+ return {
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ UploadDialog,
+ };
+} \ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 028a1953..4fec739a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^3.9.1",
+ "@mantine/hooks": "^8.0.2",
"@mui/material": "^6.2.1",
"@mui/x-data-grid-premium": "^7.23.3",
"@mui/x-tree-view": "^7.23.6",
@@ -102,7 +103,7 @@
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.0",
"sonner": "^1.7.1",
- "swr": "^2.2.5",
+ "swr": "^2.3.3",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
@@ -2129,6 +2130,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mantine/hooks": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.2.tgz",
+ "integrity": "sha512-0jpEdC0KIAZ54D5kd9rJudrEm6vkvnrL9yYHnkuNbxokXSzDdYA/wpHnKR5WW+u6fW4JF6A6A7gN1vXKeC9MSw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.x || ^19.x"
+ }
+ },
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -6523,6 +6533,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
@@ -13244,16 +13263,16 @@
}
},
"node_modules/swr": {
- "version": "2.2.5",
- "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
- "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
+ "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
"license": "MIT",
"dependencies": {
- "client-only": "^0.0.1",
- "use-sync-external-store": "^1.2.0"
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
- "react": "^16.11.0 || ^17.0.0 || ^18.0.0"
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwind-merge": {
diff --git a/package.json b/package.json
index e423a76d..84426d75 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^3.9.1",
+ "@mantine/hooks": "^8.0.2",
"@mui/material": "^6.2.1",
"@mui/x-data-grid-premium": "^7.23.3",
"@mui/x-tree-view": "^7.23.6",
@@ -104,7 +105,7 @@
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.0",
"sonner": "^1.7.1",
- "swr": "^2.2.5",
+ "swr": "^2.3.3",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",